mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Keep fire and camera selections bound to their source records
This commit is contained in:
1 parent
b01f1ee7dc
commit
c4a97a653c
7 files changed
+220
-31
No files matched your search
@@ -0,0 +1,137 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import { installAlprFixture } from './fixtures/w2/alpr.mjs';
|
||||||
|
import { firmsFixture } from './fixtures/w2/firms.mjs';
|
||||||
|
import { switchGlobe } from './qa-switch-globe.mjs';
|
||||||
|
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||||
|
const url = new URL(process.argv[2]),
|
||||||
|
fire = process.argv.includes('--fires'),
|
||||||
|
id = fire ? 'local-firms' : 'alpr-cameras';
|
||||||
|
if (
|
||||||
|
!['localhost', '127.0.0.1'].includes(url.hostname) ||
|
||||||
|
['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||||
|
url.port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw Error('Supply local keyless preview URL');
|
||||||
|
url.search = '?engine=navara&welcome=0';
|
||||||
|
url.hash =
|
||||||
|
'v=2&' +
|
||||||
|
(fire
|
||||||
|
? 'lat=37.77&lon=-122.42&alt=100000'
|
||||||
|
: 'lat=30.262&lon=-97.7431&alt=5500') +
|
||||||
|
'&pitch=-90&heading=0&map=sentinel2&l=';
|
||||||
|
const b = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--use-angle=metal', '--enable-gpu'],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const p = await b.newPage();
|
||||||
|
await blockPaidQaMaps(p);
|
||||||
|
await p.setViewport({ width: 1280, height: 900 });
|
||||||
|
const errors = [];
|
||||||
|
p.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
await installAlprFixture(p);
|
||||||
|
await p.evaluateOnNewDocument((data) => {
|
||||||
|
window.qaFirmsReads = 0;
|
||||||
|
const fetch = window.fetch.bind(window);
|
||||||
|
window.fetch = (input, opt) => {
|
||||||
|
if (
|
||||||
|
new URL(input.url ?? input, location.href).pathname === '/api/firms'
|
||||||
|
) {
|
||||||
|
window.qaFirmsReads++;
|
||||||
|
return Promise.resolve(Response.json(data));
|
||||||
|
}
|
||||||
|
return fetch(input, opt);
|
||||||
|
};
|
||||||
|
}, firmsFixture());
|
||||||
|
await p.goto(url.href);
|
||||||
|
await p.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.engine.kind === 'navara' &&
|
||||||
|
document.querySelector('#loading-screen.hidden'),
|
||||||
|
);
|
||||||
|
await p.evaluate(async (id) => {
|
||||||
|
const { dataManager, styleManager, tileset } = window.__godsEyeView;
|
||||||
|
if (window.__GOOGLE_MAPS_API_KEY__ || tileset)
|
||||||
|
throw Error('Keyless preview required');
|
||||||
|
await styleManager.initialRestorePromise;
|
||||||
|
window.qaGround = dataManager.layers.get(id).module;
|
||||||
|
await dataManager.setEnabled(id, true, { origin: 'user' });
|
||||||
|
}, id);
|
||||||
|
await p.waitForFunction(() => window.qaGround?.getStats().count > 0, {
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
const target = await p.waitForFunction(
|
||||||
|
(fire) => {
|
||||||
|
const a = window.__godsEyeView,
|
||||||
|
m = window.qaGround;
|
||||||
|
const points = fire
|
||||||
|
? m
|
||||||
|
.getDetectableObjects({ maxCount: 1000 })
|
||||||
|
.filter((v) => v.id === m.getAnalystRecords(1)[0]?.id)
|
||||||
|
: [...window.__gevContextStore.entities.values()]
|
||||||
|
.filter((v) => v.layerId === 'alpr-cameras')
|
||||||
|
.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
position:
|
||||||
|
v.entity.gevAlprCanvasPosition ||
|
||||||
|
v.entity.gevDisplayPosition?.(),
|
||||||
|
}));
|
||||||
|
for (const row of points) {
|
||||||
|
if (!row.position) continue;
|
||||||
|
const s = a.engine.projector.worldToWindow(row.position);
|
||||||
|
if (!s || s.x < 430 || s.x > 850 || s.y < 220 || s.y > 730) continue;
|
||||||
|
const picked = a.engine.picker.pick(s);
|
||||||
|
if (fire || picked?.id === row.id || picked?.id?.id === row.id)
|
||||||
|
return { x: s.x, y: s.y, id: row.id };
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
fire,
|
||||||
|
);
|
||||||
|
const xy = await target.jsonValue();
|
||||||
|
await new Promise(r=>setTimeout(r,1000));
|
||||||
|
await p.mouse.click(xy.x, xy.y);
|
||||||
|
await p.waitForFunction(
|
||||||
|
(id,fire) => fire ? window.qaGround.getSelectedInfo()?.frp === 999 : window.__gevContextStore?.selectedEntityId === id,
|
||||||
|
{},
|
||||||
|
xy.id, fire,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 1600));
|
||||||
|
const expectedId = fire ? await p.evaluate(()=>window.qaGround.getSelectedInfo().id) : xy.id;
|
||||||
|
const before = await p.evaluate(() => ({
|
||||||
|
stamp: window.qaGround.getStats().lastUpdate,
|
||||||
|
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
|
||||||
|
reads: window.qaFirmsReads,
|
||||||
|
}));
|
||||||
|
for (const kind of ['cesium', 'navara']) {
|
||||||
|
await switchGlobe(p, kind);
|
||||||
|
const result = await p.evaluate(
|
||||||
|
(id) => ({
|
||||||
|
same:
|
||||||
|
window.qaGround ===
|
||||||
|
window.__godsEyeView.dataManager.layers.get(id).module,
|
||||||
|
selected: window.__gevContextStore.selectedEntityId,
|
||||||
|
record: window.qaGround.getSelectedInfo?.()?.id,
|
||||||
|
stamp: window.qaGround.getStats().lastUpdate,
|
||||||
|
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
|
||||||
|
reads: window.qaFirmsReads,
|
||||||
|
count: window.qaGround.getStats().count,
|
||||||
|
}),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
console.log(kind, id, result.selected, result.count, result.records.length);
|
||||||
|
assert.ok(result.same && result.count);
|
||||||
|
assert.equal(result.selected, expectedId);
|
||||||
|
if (fire) assert.equal(result.record, expectedId);
|
||||||
|
assert.equal(result.reads, before.reads);
|
||||||
|
assert.deepEqual(result.records, before.records);
|
||||||
|
assert.equal(result.stamp, before.stamp);
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log('PASS retained source, stable clicked record on each planet');
|
||||||
|
} finally {
|
||||||
|
await b.close();
|
||||||
|
}
|
||||||
@@ -219,7 +219,7 @@ export function createApplicationCatalog({
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'alpr-cameras',
|
'alpr-cameras',
|
||||||
() => createApplicationAlpr({ surface, source: sources.alpr }),
|
() => sessionModel('alpr-cameras', () => createApplicationAlpr({ surface, source: sources.alpr })),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'street-level',
|
'street-level',
|
||||||
@@ -326,14 +326,14 @@ export function createApplicationCatalog({
|
|||||||
[
|
[
|
||||||
'local-firms',
|
'local-firms',
|
||||||
() =>
|
() =>
|
||||||
createApplicationFirms({
|
sessionModel('local-firms', () => createApplicationFirms({
|
||||||
surface,
|
surface,
|
||||||
id: 'local-firms',
|
id: 'local-firms',
|
||||||
name: 'FIRMS Active Fires',
|
name: 'FIRMS Active Fires',
|
||||||
icon: '▲',
|
icon: '▲',
|
||||||
source: 'NASA FIRMS · LIVE',
|
source: 'NASA FIRMS · LIVE',
|
||||||
feed: sources.firms,
|
feed: sources.firms,
|
||||||
}),
|
})),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
const available = (id) => availability.isAvailable(id);
|
const available = (id) => availability.isAvailable(id);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export function createAlprCamerasLayer({
|
|||||||
renderSaturated: false,
|
renderSaturated: false,
|
||||||
};
|
};
|
||||||
let renderer = null;
|
let renderer = null;
|
||||||
|
let restoringView = false;
|
||||||
|
|
||||||
function setAlprStatus(status, error = null) {
|
function setAlprStatus(status, error = null) {
|
||||||
if (state.status === status && state.error === error) return;
|
if (state.status === status && state.error === error) return;
|
||||||
@@ -112,7 +113,7 @@ export function createAlprCamerasLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleLoad() {
|
function scheduleLoad() {
|
||||||
if (!state.enabled) return;
|
if (!state.enabled || !renderer || restoringView) return;
|
||||||
clearUnavailableRetry({ resetBackoff: false });
|
clearUnavailableRetry({ resetBackoff: false });
|
||||||
clearTimeout(state.debounceTimer);
|
clearTimeout(state.debounceTimer);
|
||||||
// A view the accepted tiles already cover is a local filter: show it now.
|
// A view the accepted tiles already cover is a local filter: show it now.
|
||||||
@@ -130,7 +131,7 @@ export function createAlprCamerasLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCameras() {
|
async function loadCameras() {
|
||||||
if (!state.enabled || !renderer?.ready) return;
|
if (!state.enabled || !renderer?.ready || restoringView) return;
|
||||||
clearTimeout(state.debounceTimer);
|
clearTimeout(state.debounceTimer);
|
||||||
state.debounceTimer = null;
|
state.debounceTimer = null;
|
||||||
clearUnavailableRetry({ resetBackoff: false });
|
clearUnavailableRetry({ resetBackoff: false });
|
||||||
@@ -197,14 +198,14 @@ export function createAlprCamerasLayer({
|
|||||||
state.lastQueryBox = null;
|
state.lastQueryBox = null;
|
||||||
state.lastQueryZoom = null;
|
state.lastQueryZoom = null;
|
||||||
clearUnavailableRetry();
|
clearUnavailableRetry();
|
||||||
renderer.renderRecords();
|
if (!restoringView) renderer?.renderRecords();
|
||||||
setAlprStatus('zoom-in');
|
setAlprStatus('zoom-in');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
|
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
|
||||||
// Resolve marker floors (bounded wait, warm cells answer at once) so new
|
// Resolve marker floors (bounded wait, warm cells answer at once) so new
|
||||||
// markers are placed on the floor instead of clamped then moved.
|
// markers are placed on the floor instead of clamped then moved.
|
||||||
await renderer.prepareFloors(records);
|
await renderer?.prepareFloors(records);
|
||||||
if (
|
if (
|
||||||
requestAbort.signal.aborted ||
|
requestAbort.signal.aborted ||
|
||||||
state.abort !== requestAbort ||
|
state.abort !== requestAbort ||
|
||||||
@@ -230,8 +231,8 @@ export function createAlprCamerasLayer({
|
|||||||
clearUnavailableRetry();
|
clearUnavailableRetry();
|
||||||
// Stale and saturated are independent facts; a cached response that also
|
// Stale and saturated are independent facts; a cached response that also
|
||||||
// hit the cap must still warn about coverage, not just about freshness.
|
// hit the cap must still warn about coverage, not just about freshness.
|
||||||
renderer.renderRecords();
|
if (!restoringView) renderer?.renderRecords();
|
||||||
setAlprStatus(renderer.count() ? (stale ? 'stale' : 'ready') : 'empty');
|
setAlprStatus((renderer?.count() ?? records.length) ? (stale ? 'stale' : 'ready') : 'empty');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error?.name === 'AbortError' ||
|
error?.name === 'AbortError' ||
|
||||||
@@ -265,7 +266,11 @@ export function createAlprCamerasLayer({
|
|||||||
source: source.label || msg('Mapped camera locations'),
|
source: source.label || msg('Mapped camera locations'),
|
||||||
updateInterval: 0,
|
updateInterval: 0,
|
||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
init(viewer, options = {}) {
|
init(viewer, options) {
|
||||||
|
this.attachRenderer(viewer, options);
|
||||||
|
},
|
||||||
|
attachRenderer(viewer, options = {}) {
|
||||||
|
restoringView = Boolean(options.waitForCameraRestore);
|
||||||
if (renderer?.ready) throw new Error('ALPR layer is already initialized');
|
if (renderer?.ready) throw new Error('ALPR layer is already initialized');
|
||||||
if (typeof rendererFactory !== 'function')
|
if (typeof rendererFactory !== 'function')
|
||||||
throw new TypeError('An ALPR renderer factory is required');
|
throw new TypeError('An ALPR renderer factory is required');
|
||||||
@@ -276,6 +281,24 @@ export function createAlprCamerasLayer({
|
|||||||
engine: options.engine,
|
engine: options.engine,
|
||||||
});
|
});
|
||||||
renderer.init(viewer, { onMove: scheduleLoad });
|
renderer.init(viewer, { onMove: scheduleLoad });
|
||||||
|
if (state.enabled) {
|
||||||
|
renderer.setVisible(true);
|
||||||
|
if (!restoringView) renderer.renderRecords();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
detachRenderer() {
|
||||||
|
restoringView = true;
|
||||||
|
clearTimeout(state.debounceTimer);
|
||||||
|
renderer?.destroy({ retainContext: true });
|
||||||
|
renderer = null;
|
||||||
|
},
|
||||||
|
resumeViewport() {
|
||||||
|
if (!restoringView) return;
|
||||||
|
restoringView = false;
|
||||||
|
if (state.enabled && renderer) {
|
||||||
|
renderer.renderRecords();
|
||||||
|
if (!state.loading) scheduleLoad();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
enable() {
|
enable() {
|
||||||
if (state.enabled) return;
|
if (state.enabled) return;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function createAlprCesiumRenderer({ state, services, source }) {
|
|||||||
if (visible) native.creditPresented = false;
|
if (visible) native.creditPresented = false;
|
||||||
if (native.dataSource) native.dataSource.show = visible;
|
if (native.dataSource) native.dataSource.show = visible;
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy(options) {
|
||||||
presentation.destroyOverlay();
|
presentation.destroyOverlay();
|
||||||
removeMove?.();
|
removeMove?.();
|
||||||
removeMove = null;
|
removeMove = null;
|
||||||
@@ -52,7 +52,7 @@ export function createAlprCesiumRenderer({ state, services, source }) {
|
|||||||
removeFrame = null;
|
removeFrame = null;
|
||||||
native.clickHandler?.destroy();
|
native.clickHandler?.destroy();
|
||||||
native.clickHandler = null;
|
native.clickHandler = null;
|
||||||
presentation.clearRendered();
|
presentation.clearRendered(options);
|
||||||
if (native.dataSource && native.viewer)
|
if (native.dataSource && native.viewer)
|
||||||
native.viewer.dataSources.remove(native.dataSource, true);
|
native.viewer.dataSources.remove(native.dataSource, true);
|
||||||
native.dataSource = null;
|
native.dataSource = null;
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ export function createAlprPresentation({ state, native, services, source }) {
|
|||||||
return { south, west, north, east };
|
return { south, west, north, east };
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRendered() {
|
function clearRendered({ retainContext = false } = {}) {
|
||||||
hideOnMapCredit();
|
hideOnMapCredit();
|
||||||
floors.cancel();
|
floors.cancel();
|
||||||
floorResolve = null;
|
floorResolve = null;
|
||||||
@@ -312,7 +312,7 @@ export function createAlprPresentation({ state, native, services, source }) {
|
|||||||
visibleRecords = [];
|
visibleRecords = [];
|
||||||
clampedMarkers.clear();
|
clampedMarkers.clear();
|
||||||
if (native.dataSource?.entities) native.dataSource.entities.removeAll();
|
if (native.dataSource?.entities) native.dataSource.entities.removeAll();
|
||||||
removeEntityContextsForLayer(LAYER_ID);
|
if (!retainContext) removeEntityContextsForLayer(LAYER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideOnMapCredit() {
|
function hideOnMapCredit() {
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ export function createAlprNavaraRenderer(engine, { state, services, source }) {
|
|||||||
else hideOnMapCredit();
|
else hideOnMapCredit();
|
||||||
request();
|
request();
|
||||||
}
|
}
|
||||||
function clearRendered() {
|
function clearRendered({ retainContext = false } = {}) {
|
||||||
hideOnMapCredit();
|
hideOnMapCredit();
|
||||||
generation++;
|
generation++;
|
||||||
floors.cancel();
|
floors.cancel();
|
||||||
@@ -464,7 +464,7 @@ export function createAlprNavaraRenderer(engine, { state, services, source }) {
|
|||||||
selected.commit();
|
selected.commit();
|
||||||
spritesDirty = false;
|
spritesDirty = false;
|
||||||
}
|
}
|
||||||
services.context.removeEntityContextsForLayer(LAYER_ID);
|
if (!retainContext) services.context.removeEntityContextsForLayer(LAYER_ID);
|
||||||
}
|
}
|
||||||
function frame() {
|
function frame() {
|
||||||
const camera = engine.camera.livePosition;
|
const camera = engine.camera.livePosition;
|
||||||
@@ -582,9 +582,9 @@ export function createAlprNavaraRenderer(engine, { state, services, source }) {
|
|||||||
request();
|
request();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy(options) {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
clearRendered();
|
clearRendered(options);
|
||||||
overlay.destroy();
|
overlay.destroy();
|
||||||
removeClick?.();
|
removeClick?.();
|
||||||
removeClick = null;
|
removeClick = null;
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export function createLifecycle({
|
|||||||
const { id, overlayHost } = config;
|
const { id, overlayHost } = config;
|
||||||
|
|
||||||
const methods = {
|
const methods = {
|
||||||
init(viewer, { engine = null } = {}) {
|
init(viewer, options) {
|
||||||
|
this.attachRenderer(viewer, options);
|
||||||
|
},
|
||||||
|
attachRenderer(viewer, { engine = null, waitForCameraRestore = false } = {}) {
|
||||||
if (layerState._destroyed) return;
|
if (layerState._destroyed) return;
|
||||||
if (layerState._renderer) return;
|
if (layerState._renderer) return;
|
||||||
layerState._renderer = config.renderer.create({
|
layerState._renderer = config.renderer.create({
|
||||||
@@ -25,14 +28,36 @@ export function createLifecycle({
|
|||||||
layerState._viewer = layerState._renderer.view;
|
layerState._viewer = layerState._renderer.view;
|
||||||
layerState._dataSource = layerState._renderer.dataSource;
|
layerState._dataSource = layerState._renderer.dataSource;
|
||||||
layerState._renderer.setVisible(false);
|
layerState._renderer.setVisible(false);
|
||||||
|
layerState._restoringViewport = waitForCameraRestore;
|
||||||
|
if (layerState._enabled && !waitForCameraRestore) {
|
||||||
|
this.showRenderer();
|
||||||
|
layerState._renderer.onEnable();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async enable(viewer) {
|
resumeViewport() {
|
||||||
if (layerState._destroyed) return;
|
if (!layerState._restoringViewport) return;
|
||||||
layerState._enabled = true;
|
layerState._restoringViewport = false;
|
||||||
if (!layerState._dataSource) this.init(viewer);
|
if (layerState._enabled && layerState._renderer) {
|
||||||
const renderer = layerState._renderer;
|
this.showRenderer();
|
||||||
layerState._viewer = layerState._renderer.view;
|
layerState._renderer.onEnable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
detachRenderer() {
|
||||||
|
components.selection.removeClickHandler();
|
||||||
|
components.viewport.removeMoveEndWatcher();
|
||||||
|
components.viewport.removeLodWatcher();
|
||||||
|
unregisterPickOwner(id);
|
||||||
|
components.rendering.removeDetectionCollections(layerState._viewer);
|
||||||
|
layerState._renderer?.destroy();
|
||||||
|
layerState._renderer = layerState._viewer = layerState._dataSource = null;
|
||||||
|
layerState._pickIndexById.clear();
|
||||||
|
layerState._labelCandidates = [];
|
||||||
|
layerState._camSnapValid = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
showRenderer() {
|
||||||
if (layerState._billboards) {
|
if (layerState._billboards) {
|
||||||
// The camera can have moved anywhere while the layer was off: moveEnd
|
// The camera can have moved anywhere while the layer was off: moveEnd
|
||||||
// was not being listened to, and the preRender watcher is inert while
|
// was not being listened to, and the preRender watcher is inert while
|
||||||
@@ -51,14 +76,18 @@ export function createLifecycle({
|
|||||||
registerPickOwner(id, (pickedId) =>
|
registerPickOwner(id, (pickedId) =>
|
||||||
layerState._pickIndexById.has(pickedId),
|
layerState._pickIndexById.has(pickedId),
|
||||||
);
|
);
|
||||||
|
components.rendering.renderCurrentLod(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
async enable(viewer) {
|
||||||
|
if (layerState._destroyed) return;
|
||||||
|
if (!layerState._dataSource) this.init(viewer);
|
||||||
|
layerState._enabled = true;
|
||||||
|
const renderer = layerState._renderer;
|
||||||
|
this.showRenderer();
|
||||||
if (!layerState._fires.length && !layerState._loading)
|
if (!layerState._fires.length && !layerState._loading)
|
||||||
await components.ingestion.loadHeatmap();
|
await components.ingestion.loadHeatmap();
|
||||||
if (
|
if (layerState._enabled && layerState._renderer === renderer) renderer.onEnable();
|
||||||
layerState._enabled &&
|
|
||||||
!layerState._destroyed &&
|
|
||||||
layerState._renderer === renderer
|
|
||||||
)
|
|
||||||
renderer.onEnable();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
|
|||||||
Reference in new issue
Block a user