mirror of
https://github.com/maplibre/maplibre-rs.git
synced 2025-12-08 19:05:57 +00:00
Prepare JS lib
This commit is contained in:
parent
ea52e9c96d
commit
1f3423a70c
@ -1,15 +1,8 @@
|
|||||||
[target.wasm32-unknown-unknown]
|
[target.wasm32-unknown-unknown]
|
||||||
rustflags = [
|
rustflags = [
|
||||||
# Enabled unstable APIs from web_sys
|
# Enabled unstable APIs from web_sys
|
||||||
"--cfg=web_sys_unstable_apis",
|
"--cfg=web_sys_unstable_apis"
|
||||||
# Enables features which are required for shared-memory
|
|
||||||
"-C", "target-feature=+atomics,+bulk-memory,+mutable-globals",
|
|
||||||
# Enables the possibility to import memory into wasm.
|
|
||||||
# Without --shared-memory it is not possible to use shared WebAssembly.Memory.
|
|
||||||
"-C", "link-args=--shared-memory --import-memory",
|
|
||||||
]
|
]
|
||||||
runner = 'wasm-bindgen-test-runner'
|
|
||||||
|
|
||||||
|
|
||||||
[profile.wasm-dev]
|
[profile.wasm-dev]
|
||||||
inherits = "dev"
|
inherits = "dev"
|
||||||
|
|||||||
@ -11,7 +11,7 @@ module it can resolve WebAssembly files or WebWorkers dynamically.
|
|||||||
The following syntax is used to resolve referenced WebWorkers:
|
The following syntax is used to resolve referenced WebWorkers:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
new Worker(new URL("./pool.worker.ts", import.meta.url), {
|
new Worker(new URL("./multithreaded-pool.worker.ts", import.meta.url), {
|
||||||
type: 'module'
|
type: 'module'
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@ -107,7 +107,7 @@ See config in `web/lib/build.mjs` for an example usage.
|
|||||||
### Babel & TypeScript
|
### Babel & TypeScript
|
||||||
|
|
||||||
Babel and TypeScript both can produce ESM modules, but they **fail with transforming references within the source code**
|
Babel and TypeScript both can produce ESM modules, but they **fail with transforming references within the source code**
|
||||||
like `new URL("./pool.worker.ts", import.meta.url)`. There exist some Babel plugins, but none of them is stable.
|
like `new URL("./multithreaded-pool.worker.ts", import.meta.url)`. There exist some Babel plugins, but none of them is stable.
|
||||||
Therefore, we actually need a proper bundler which supports outputting ESM modules.
|
Therefore, we actually need a proper bundler which supports outputting ESM modules.
|
||||||
The only stable solution to this is Parcel. Parcel also has good documentation around the bundling of WebWorkers.
|
The only stable solution to this is Parcel. Parcel also has good documentation around the bundling of WebWorkers.
|
||||||
|
|
||||||
@ -277,4 +277,4 @@ Example config in `package.json:
|
|||||||
|
|
||||||
### Rollup
|
### Rollup
|
||||||
|
|
||||||
Not yet evaluated
|
Not yet evaluated
|
||||||
|
|||||||
1
web/lib/@types/env/index.d.ts
vendored
1
web/lib/@types/env/index.d.ts
vendored
@ -1 +1,2 @@
|
|||||||
declare const WEBGL: boolean
|
declare const WEBGL: boolean
|
||||||
|
declare const MULTITHREADED: boolean
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {build} from 'esbuild';
|
|||||||
import metaUrlPlugin from '@chialab/esbuild-plugin-meta-url';
|
import metaUrlPlugin from '@chialab/esbuild-plugin-meta-url';
|
||||||
import inlineWorker from 'esbuild-plugin-inline-worker';
|
import inlineWorker from 'esbuild-plugin-inline-worker';
|
||||||
import yargs from "yargs";
|
import yargs from "yargs";
|
||||||
|
import process from "process";
|
||||||
import chokidar from "chokidar";
|
import chokidar from "chokidar";
|
||||||
import {spawnSync} from "child_process"
|
import {spawnSync} from "child_process"
|
||||||
import {dirname} from "path";
|
import {dirname} from "path";
|
||||||
@ -9,32 +10,30 @@ import {fileURLToPath} from "url";
|
|||||||
|
|
||||||
let argv = yargs(process.argv.slice(2))
|
let argv = yargs(process.argv.slice(2))
|
||||||
.option('watch', {
|
.option('watch', {
|
||||||
alias: 'w',
|
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Enable watching'
|
description: 'Enable watching'
|
||||||
})
|
})
|
||||||
.option('release', {
|
.option('release', {
|
||||||
alias: 'r',
|
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Release mode'
|
description: 'Release mode'
|
||||||
})
|
})
|
||||||
.option('webgl', {
|
.option('webgl', {
|
||||||
alias: 'g',
|
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Enable webgl'
|
description: 'Enable webgl'
|
||||||
})
|
})
|
||||||
|
.option('multithreaded', {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Enable multithreaded support'
|
||||||
|
})
|
||||||
.option('esm', {
|
.option('esm', {
|
||||||
alias: 'e',
|
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Enable esm'
|
description: 'Enable esm'
|
||||||
})
|
})
|
||||||
.option('cjs', {
|
.option('cjs', {
|
||||||
alias: 'c',
|
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Enable cjs'
|
description: 'Enable cjs'
|
||||||
})
|
})
|
||||||
.option('iife', {
|
.option('iife', {
|
||||||
alias: 'i',
|
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Enable iife'
|
description: 'Enable iife'
|
||||||
})
|
})
|
||||||
@ -44,6 +43,7 @@ let esm = argv.esm;
|
|||||||
let iife = argv.iife;
|
let iife = argv.iife;
|
||||||
let cjs = argv.cjs;
|
let cjs = argv.cjs;
|
||||||
let release = argv.release;
|
let release = argv.release;
|
||||||
|
let multithreaded = argv.multithreaded;
|
||||||
|
|
||||||
if (!esm && !iife && !cjs) {
|
if (!esm && !iife && !cjs) {
|
||||||
console.warn("Enabling ESM bundling as no other bundle is enabled.")
|
console.warn("Enabling ESM bundling as no other bundle is enabled.")
|
||||||
@ -56,19 +56,29 @@ if (webgl) {
|
|||||||
console.log("WebGL support enabled.")
|
console.log("WebGL support enabled.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseSettings = {
|
if (multithreaded) {
|
||||||
entryPoints: ['src/index.ts'],
|
console.log("multithreaded support enabled.")
|
||||||
bundle: true,
|
}
|
||||||
|
|
||||||
|
let baseConfig = {
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
|
bundle: true,
|
||||||
assetNames: "assets/[name]",
|
assetNames: "assets/[name]",
|
||||||
define: {"WEBGL": `${webgl}`},
|
define: {
|
||||||
|
WEBGL: `${webgl}`,
|
||||||
|
MULTITHREADED: `${multithreaded}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = {
|
||||||
|
...baseConfig,
|
||||||
|
entryPoints: ['src/index.ts'],
|
||||||
incremental: argv.watch,
|
incremental: argv.watch,
|
||||||
plugins: [
|
plugins: [
|
||||||
inlineWorker({
|
inlineWorker({
|
||||||
format: "cjs", platform: "browser",
|
...baseConfig,
|
||||||
|
format: "cjs",
|
||||||
target: 'es2022',
|
target: 'es2022',
|
||||||
bundle: true,
|
|
||||||
assetNames: "assets/[name]",
|
|
||||||
}),
|
}),
|
||||||
metaUrlPlugin()
|
metaUrlPlugin()
|
||||||
],
|
],
|
||||||
@ -119,11 +129,22 @@ const wasmPack = () => {
|
|||||||
let outDirectory = `${getLibDirectory()}/src/wasm`;
|
let outDirectory = `${getLibDirectory()}/src/wasm`;
|
||||||
let profile = release ? "wasm-release" : "wasm-dev"
|
let profile = release ? "wasm-release" : "wasm-dev"
|
||||||
|
|
||||||
let cargo = spawnSync('cargo', ["build",
|
// language=toml
|
||||||
|
let multithreaded_config = `target.wasm32-unknown-unknown.rustflags = [
|
||||||
|
# Enables features which are required for shared-memory
|
||||||
|
"-C", "target-feature=+atomics,+bulk-memory,+mutable-globals",
|
||||||
|
# Enables the possibility to import memory into wasm.
|
||||||
|
# Without --shared-memory it is not possible to use shared WebAssembly.Memory.
|
||||||
|
"-C", "link-args=--shared-memory --import-memory",
|
||||||
|
]`
|
||||||
|
|
||||||
|
let cargo = spawnSync('cargo', [
|
||||||
|
...(multithreaded ? ["--config", multithreaded_config] : []),
|
||||||
|
"build",
|
||||||
"-p", "web", "--lib",
|
"-p", "web", "--lib",
|
||||||
"--target", "wasm32-unknown-unknown",
|
"--target", "wasm32-unknown-unknown",
|
||||||
"--profile", profile,
|
"--profile", profile,
|
||||||
"--features", `${webgl ? "web-webgl" : ""}`,
|
"--features", `${webgl ? "web-webgl," : ""}`,
|
||||||
"-Z", "build-std=std,panic_abort"
|
"-Z", "build-std=std,panic_abort"
|
||||||
], {
|
], {
|
||||||
cwd: '.',
|
cwd: '.',
|
||||||
@ -206,7 +227,7 @@ const watchResult = async (result) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const esbuild = async (name, globalName = undefined) => {
|
const esbuild = async (name, globalName = undefined) => {
|
||||||
let result = await build({...baseSettings, format: name, globalName, outfile: `dist/esbuild-${name}/module.js`,});
|
let result = await build({...config, format: name, globalName, outfile: `dist/esbuild-${name}/module.js`,});
|
||||||
|
|
||||||
if (argv.watch) {
|
if (argv.watch) {
|
||||||
console.log("Watching is enabled.")
|
console.log("Watching is enabled.")
|
||||||
|
|||||||
73
web/lib/src/browser.ts
Normal file
73
web/lib/src/browser.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
bigInt,
|
||||||
|
bulkMemory,
|
||||||
|
exceptions,
|
||||||
|
multiValue,
|
||||||
|
mutableGlobals,
|
||||||
|
referenceTypes,
|
||||||
|
saturatedFloatToInt,
|
||||||
|
signExtensions,
|
||||||
|
simd,
|
||||||
|
tailCall,
|
||||||
|
threads
|
||||||
|
} from "wasm-feature-detect"
|
||||||
|
|
||||||
|
|
||||||
|
export const checkRequirements = () => {
|
||||||
|
if (MULTITHREADED) {
|
||||||
|
if (!isSecureContext) {
|
||||||
|
return "isSecureContext is false!"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!crossOriginIsolated) {
|
||||||
|
return "crossOriginIsolated is false! " +
|
||||||
|
"The Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy HTTP headers are required."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WEBGL) {
|
||||||
|
if (!isWebGLSupported()) {
|
||||||
|
return "WebGL is not supported in this Browser!"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!("gpu" in navigator)) {
|
||||||
|
return "WebGPU is not supported in this Browser!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isWebGLSupported = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.getContext("webgl")
|
||||||
|
return true
|
||||||
|
} catch (x) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkWasmFeatures = async () => {
|
||||||
|
const checkFeature = async function (featureName: string, feature: () => Promise<boolean>) {
|
||||||
|
let result = await feature();
|
||||||
|
let msg = `The feature ${featureName} returned: ${result}`;
|
||||||
|
if (result) {
|
||||||
|
console.log(msg);
|
||||||
|
} else {
|
||||||
|
console.warn(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkFeature("bulkMemory", bulkMemory);
|
||||||
|
await checkFeature("exceptions", exceptions);
|
||||||
|
await checkFeature("multiValue", multiValue);
|
||||||
|
await checkFeature("mutableGlobals", mutableGlobals);
|
||||||
|
await checkFeature("referenceTypes", referenceTypes);
|
||||||
|
await checkFeature("saturatedFloatToInt", saturatedFloatToInt);
|
||||||
|
await checkFeature("signExtensions", signExtensions);
|
||||||
|
await checkFeature("simd", simd);
|
||||||
|
await checkFeature("tailCall", tailCall);
|
||||||
|
await checkFeature("threads", threads);
|
||||||
|
await checkFeature("bigInt", bigInt);
|
||||||
|
}
|
||||||
@ -1,92 +1,12 @@
|
|||||||
import init, {create_pool_scheduler, run} from "./wasm/maplibre"
|
import {create_pool_scheduler, run} from "./wasm/maplibre"
|
||||||
import {Spector} from "spectorjs"
|
import {Spector} from "spectorjs"
|
||||||
import {WebWorkerMessageType} from "./worker-types"
|
// @ts-ignore esbuild plugin is handling this
|
||||||
import {
|
import MultithreadedPoolWorker from './multithreaded-pool.worker.js';
|
||||||
bigInt,
|
// @ts-ignore esbuild plugin is handling this
|
||||||
bulkMemory,
|
|
||||||
exceptions,
|
|
||||||
multiValue,
|
|
||||||
mutableGlobals,
|
|
||||||
referenceTypes,
|
|
||||||
saturatedFloatToInt,
|
|
||||||
signExtensions,
|
|
||||||
simd,
|
|
||||||
tailCall,
|
|
||||||
threads
|
|
||||||
} from "wasm-feature-detect"
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
import PoolWorker from './pool.worker.js';
|
import PoolWorker from './pool.worker.js';
|
||||||
|
|
||||||
const isWebGLSupported = () => {
|
import {initialize} from "./module";
|
||||||
try {
|
import {checkRequirements, checkWasmFeatures} from "./browser";
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
canvas.getContext("webgl")
|
|
||||||
return true
|
|
||||||
} catch (x) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkWasmFeatures = async () => {
|
|
||||||
const checkFeature = async function (featureName: string, feature: () => Promise<boolean>) {
|
|
||||||
let result = await feature();
|
|
||||||
let msg = `The feature ${featureName} returned: ${result}`;
|
|
||||||
if (result) {
|
|
||||||
console.log(msg);
|
|
||||||
} else {
|
|
||||||
console.warn(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await checkFeature("bulkMemory", bulkMemory);
|
|
||||||
await checkFeature("exceptions", exceptions);
|
|
||||||
await checkFeature("multiValue", multiValue);
|
|
||||||
await checkFeature("mutableGlobals", mutableGlobals);
|
|
||||||
await checkFeature("referenceTypes", referenceTypes);
|
|
||||||
await checkFeature("saturatedFloatToInt", saturatedFloatToInt);
|
|
||||||
await checkFeature("signExtensions", signExtensions);
|
|
||||||
await checkFeature("simd", simd);
|
|
||||||
await checkFeature("tailCall", tailCall);
|
|
||||||
await checkFeature("threads", threads);
|
|
||||||
await checkFeature("bigInt", bigInt);
|
|
||||||
}
|
|
||||||
|
|
||||||
const alertUser = (message: string) => {
|
|
||||||
console.error(message)
|
|
||||||
alert(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkRequirements = () => {
|
|
||||||
if (!isSecureContext) {
|
|
||||||
alertUser("isSecureContext is false!")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!crossOriginIsolated) {
|
|
||||||
alertUser("crossOriginIsolated is false! " +
|
|
||||||
"The Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy HTTP headers are required.")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WEBGL) {
|
|
||||||
if (!isWebGLSupported()) {
|
|
||||||
alertUser("WebGL is not supported in this Browser!")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let spector = new Spector()
|
|
||||||
spector.displayUI()
|
|
||||||
} else {
|
|
||||||
if (!("gpu" in navigator)) {
|
|
||||||
let message = "WebGPU is not supported in this Browser!"
|
|
||||||
alertUser(message)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const preventDefaultTouchActions = () => {
|
const preventDefaultTouchActions = () => {
|
||||||
document.body.querySelectorAll("canvas").forEach(canvas => {
|
document.body.querySelectorAll("canvas").forEach(canvas => {
|
||||||
@ -98,23 +18,28 @@ const preventDefaultTouchActions = () => {
|
|||||||
export const startMapLibre = async (wasmPath: string | undefined, workerPath: string | undefined) => {
|
export const startMapLibre = async (wasmPath: string | undefined, workerPath: string | undefined) => {
|
||||||
await checkWasmFeatures()
|
await checkWasmFeatures()
|
||||||
|
|
||||||
if (!checkRequirements()) {
|
let message = checkRequirements();
|
||||||
|
if (message) {
|
||||||
|
console.error(message)
|
||||||
|
alert(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (WEBGL) {
|
||||||
|
let spector = new Spector()
|
||||||
|
spector.displayUI()
|
||||||
|
}
|
||||||
|
|
||||||
preventDefaultTouchActions();
|
preventDefaultTouchActions();
|
||||||
|
|
||||||
let MEMORY_PAGES = 16 * 1024
|
await initialize(wasmPath);
|
||||||
|
|
||||||
const memory = new WebAssembly.Memory({initial: 1024, maximum: MEMORY_PAGES, shared: true})
|
|
||||||
await init(wasmPath, memory)
|
|
||||||
|
|
||||||
const schedulerPtr = create_pool_scheduler(() => {
|
const schedulerPtr = create_pool_scheduler(() => {
|
||||||
let worker = workerPath ? new PoolWorker(workerPath, {
|
let CurrentWorker = MULTITHREADED ? MultithreadedPoolWorker : PoolWorker;
|
||||||
type: 'module'
|
|
||||||
}) : PoolWorker({name: "test"});
|
|
||||||
|
|
||||||
return worker;
|
return workerPath ? new Worker(workerPath, {
|
||||||
|
type: 'module'
|
||||||
|
}) : CurrentWorker();
|
||||||
})
|
})
|
||||||
|
|
||||||
await run(schedulerPtr)
|
await run(schedulerPtr)
|
||||||
|
|||||||
28
web/lib/src/module.ts
Normal file
28
web/lib/src/module.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import init from "./wasm/maplibre";
|
||||||
|
|
||||||
|
export const initialize = async (wasmPath: string) => {
|
||||||
|
if (MULTITHREADED) {
|
||||||
|
await initializeSharedModule(wasmPath)
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
await init(wasmPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initializeExisting = async (module: string, memory?: string) => {
|
||||||
|
if (MULTITHREADED) {
|
||||||
|
// @ts-ignore
|
||||||
|
await init(module, memory)
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
await init(module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeSharedModule = async (wasmPath) => {
|
||||||
|
let MEMORY_PAGES = 16 * 1024
|
||||||
|
|
||||||
|
const memory = new WebAssembly.Memory({initial: 1024, maximum: MEMORY_PAGES, shared: true})
|
||||||
|
// @ts-ignore
|
||||||
|
await init(wasmPath, memory)
|
||||||
|
}
|
||||||
19
web/lib/src/multithreaded-pool.worker.ts
Normal file
19
web/lib/src/multithreaded-pool.worker.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import {worker_entry} from "./wasm/maplibre"
|
||||||
|
import {initializeExisting} from "./module";
|
||||||
|
|
||||||
|
onmessage = async message => {
|
||||||
|
const initialised = initializeExisting(message.data[0], message.data[1]).catch(err => {
|
||||||
|
// Propagate to main `onerror`:
|
||||||
|
setTimeout(() => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
// Rethrow to keep promise rejected and prevent execution of further commands:
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
self.onmessage = async message => {
|
||||||
|
// This will queue further commands up until the module is fully initialised:
|
||||||
|
await initialised;
|
||||||
|
await worker_entry(message.data);
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,18 +1,5 @@
|
|||||||
import init, {worker_entry} from "./wasm/maplibre"
|
import {initializeExisting} from "./module";
|
||||||
|
|
||||||
onmessage = async message => {
|
onmessage = async message => {
|
||||||
const initialised = init(message.data[0], message.data[1]).catch(err => {
|
|
||||||
// Propagate to main `onerror`:
|
|
||||||
setTimeout(() => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
// Rethrow to keep promise rejected and prevent execution of further commands:
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
self.onmessage = async message => {
|
|
||||||
// This will queue further commands up until the module is fully initialised:
|
|
||||||
await initialised;
|
|
||||||
worker_entry(message.data);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
/*
|
|
||||||
import {registerRoute} from 'workbox-routing';
|
|
||||||
import {CacheFirst} from 'workbox-strategies';
|
|
||||||
import {CacheableResponsePlugin} from 'workbox-cacheable-response';
|
|
||||||
|
|
||||||
registerRoute(
|
|
||||||
({url}) => url.pathname.endsWith('pbf'),
|
|
||||||
new CacheFirst({
|
|
||||||
cacheName: 'pbf-cache',
|
|
||||||
plugins: [
|
|
||||||
new CacheableResponsePlugin({
|
|
||||||
statuses: [0, 200],
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
);
|
|
||||||
*/
|
|
||||||
Loading…
x
Reference in New Issue
Block a user