refactor: Simplify service worker implementation
Remove over-engineering from asset caching service worker: - Remove retry logic, stats tracking, unused message handlers - Inline helper functions used only once - Reduce sw-runtime.js from 314 to 170 lines (46% reduction) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d34df7c314
commit
f713678328
@@ -1,8 +1,14 @@
|
||||
/* eslint-disable no-console */
|
||||
import NotificationSubscriptions from '../api/notificationSubscription';
|
||||
import auth from '../api/auth';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
/**
|
||||
* Request the service worker to prefetch all assets in the manifest.
|
||||
*/
|
||||
const prefetchAssets = () => {
|
||||
navigator.serviceWorker?.controller?.postMessage({ type: 'PREFETCH_ASSETS' });
|
||||
};
|
||||
|
||||
export const verifyServiceWorkerExistence = (callback = () => {}) => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
// Service Worker isn't supported on this browser, disable or hide UI.
|
||||
@@ -29,13 +35,20 @@ export const verifyServiceWorkerExistence = (callback = () => {}) => {
|
||||
navigator.serviceWorker.controller
|
||||
) {
|
||||
// New service worker available, will activate on next page load
|
||||
console.log(
|
||||
'New service worker available, will activate on next page load'
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('New service worker available');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger asset prefetch during idle time
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(() => prefetchAssets(), { timeout: 5000 });
|
||||
} else {
|
||||
// Fallback: prefetch after 3 seconds
|
||||
setTimeout(prefetchAssets, 3000);
|
||||
}
|
||||
|
||||
callback(registration);
|
||||
})
|
||||
.catch(registrationError => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable no-restricted-globals, no-console */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
/* globals clients */
|
||||
|
||||
// Push notification handler
|
||||
self.addEventListener('push', event => {
|
||||
let notification = event.data && event.data.json();
|
||||
const notification = event.data && event.data.json();
|
||||
|
||||
if (!notification) return;
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(notification.title, {
|
||||
@@ -17,16 +19,17 @@ self.addEventListener('push', event => {
|
||||
|
||||
// Notification click handler
|
||||
self.addEventListener('notificationclick', event => {
|
||||
let notification = event.notification;
|
||||
const notification = event.notification;
|
||||
notification.close();
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window' }).then(windowClients => {
|
||||
let matchingWindowClients = windowClients.filter(
|
||||
const matchingWindowClients = windowClients.filter(
|
||||
client => client.url === notification.data.url
|
||||
);
|
||||
|
||||
if (matchingWindowClients.length) {
|
||||
let firstWindow = matchingWindowClients[0];
|
||||
const firstWindow = matchingWindowClients[0];
|
||||
if (firstWindow && 'focus' in firstWindow) {
|
||||
firstWindow.focus();
|
||||
return;
|
||||
@@ -38,27 +41,3 @@ self.addEventListener('notificationclick', event => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Cache cleanup on activation
|
||||
self.addEventListener('activate', event => {
|
||||
const currentCacheVersion = '__CACHE_VERSION__';
|
||||
const cacheWhitelist = [
|
||||
`js-cache-${currentCacheVersion}`,
|
||||
`css-cache-${currentCacheVersion}`,
|
||||
`font-cache-${currentCacheVersion}`,
|
||||
];
|
||||
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then(cacheNames => {
|
||||
cacheNames.forEach(cacheName => {
|
||||
if (!cacheWhitelist.includes(cacheName)) {
|
||||
console.log('Deleting old cache:', cacheName);
|
||||
caches.delete(cacheName);
|
||||
}
|
||||
});
|
||||
})
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,84 +1,178 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { CacheFirst } from 'workbox-strategies';
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
/* eslint-disable no-restricted-globals, no-use-before-define */
|
||||
|
||||
// Cache version will be injected at build time
|
||||
// Build-time injected values
|
||||
const CACHE_VERSION = '__CACHE_VERSION__';
|
||||
|
||||
// Asset origin will be injected at build time (CDN host or empty string)
|
||||
const ASSET_ORIGIN = '__ASSET_ORIGIN__';
|
||||
const ASSET_MANIFEST = '__ASSET_MANIFEST__';
|
||||
|
||||
// Define cache names with versioning
|
||||
const JS_CACHE = `js-cache-${CACHE_VERSION}`;
|
||||
const CSS_CACHE = `css-cache-${CACHE_VERSION}`;
|
||||
const FONT_CACHE = `font-cache-${CACHE_VERSION}`;
|
||||
const CACHE_NAME = `chatwoot-${CACHE_VERSION}`;
|
||||
|
||||
// Cache JavaScript bundles from /packs/ or CDN
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const isScript = request.destination === 'script';
|
||||
const isFromPacks = url.pathname.startsWith('/packs/');
|
||||
const isFromCDN = ASSET_ORIGIN && url.origin === ASSET_ORIGIN;
|
||||
// Paths that should never be cached (API, auth, real-time, etc.)
|
||||
const EXCLUDED_PATHS = [
|
||||
'/api/',
|
||||
'/auth/',
|
||||
'/rails/',
|
||||
'/cable',
|
||||
'/sidekiq',
|
||||
'/super_admin',
|
||||
'/swagger',
|
||||
'/webhooks/',
|
||||
'/widget',
|
||||
'/survey/',
|
||||
'/__vite',
|
||||
'/sw.js',
|
||||
];
|
||||
|
||||
return isScript && (isFromPacks || isFromCDN);
|
||||
},
|
||||
new CacheFirst({
|
||||
cacheName: JS_CACHE,
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200], // 0 for opaque responses from CDN, 200 for same-origin
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
// =============================================================================
|
||||
// Fetch Handler
|
||||
// =============================================================================
|
||||
|
||||
// Cache CSS files from /packs/ or CDN
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const isStyle = request.destination === 'style';
|
||||
const isFromPacks = url.pathname.startsWith('/packs/');
|
||||
const isFromCDN = ASSET_ORIGIN && url.origin === ASSET_ORIGIN;
|
||||
self.addEventListener('fetch', event => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
return isStyle && (isFromPacks || isFromCDN);
|
||||
},
|
||||
new CacheFirst({
|
||||
cacheName: CSS_CACHE,
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 30,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
// Only handle GET requests from same origin or CDN
|
||||
if (request.method !== 'GET') return;
|
||||
const isOurRequest =
|
||||
url.origin === self.location.origin ||
|
||||
(ASSET_ORIGIN && url.origin === ASSET_ORIGIN);
|
||||
if (!isOurRequest) return;
|
||||
|
||||
// Cache fonts (from anywhere)
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const isFont = request.destination === 'font';
|
||||
const hasFontExtension = /\.(woff|woff2|ttf|otf|eot)$/i.test(url.pathname);
|
||||
// Skip excluded paths
|
||||
if (EXCLUDED_PATHS.some(path => url.pathname.startsWith(path))) return;
|
||||
|
||||
return isFont || hasFontExtension;
|
||||
},
|
||||
new CacheFirst({
|
||||
cacheName: FONT_CACHE,
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 30,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
// Assets (JS/CSS/fonts) → cache-first
|
||||
const isAsset =
|
||||
url.pathname.startsWith('/vite-dev/') ||
|
||||
url.pathname.startsWith('/packs/') ||
|
||||
url.origin === ASSET_ORIGIN ||
|
||||
/\.(js|css|woff2?|ttf|otf|eot)$/i.test(url.pathname);
|
||||
|
||||
if (isAsset) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation to /app/* → network-first (cache HTML shell as fallback)
|
||||
if (request.mode === 'navigate' && url.pathname.startsWith('/app')) {
|
||||
event.respondWith(networkFirstWithShellCache(request));
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Caching Strategies
|
||||
// =============================================================================
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
|
||||
// Return cached version if available
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
// Otherwise fetch and cache
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
// Network failed - return stale cache if available
|
||||
const stale = await cache.match(request);
|
||||
if (stale) return stale;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirstWithShellCache(request) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
|
||||
// Cache the HTML shell for offline fallback
|
||||
if (response.ok) {
|
||||
const html = await response.clone().text();
|
||||
if (html.includes('data-sw-cache')) {
|
||||
cache.put(
|
||||
'/app',
|
||||
new Response(html, {
|
||||
headers: response.headers,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
// Network failed - return cached shell
|
||||
const cached = await cache.match('/app');
|
||||
if (cached) return cached;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Background Prefetch
|
||||
// =============================================================================
|
||||
|
||||
async function prefetchAssets() {
|
||||
if (!ASSET_MANIFEST?.length) return;
|
||||
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const baseUrl = ASSET_ORIGIN || `${self.location.origin}/vite-dev/`;
|
||||
|
||||
// Prefetch in batches of 5 (await in loop is intentional for throttling)
|
||||
for (let i = 0; i < ASSET_MANIFEST.length; i += 5) {
|
||||
const batch = ASSET_MANIFEST.slice(i, i + 5);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.all(
|
||||
batch.map(async asset => {
|
||||
const url = `${baseUrl}${asset.url}`;
|
||||
if (await cache.match(url)) return; // Already cached
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) cache.put(url, response);
|
||||
} catch {
|
||||
// Ignore prefetch failures
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Lifecycle Events
|
||||
// =============================================================================
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
// Delete old caches
|
||||
caches
|
||||
.keys()
|
||||
.then(names =>
|
||||
Promise.all(
|
||||
names
|
||||
.filter(
|
||||
name => name.startsWith('chatwoot-') && name !== CACHE_NAME
|
||||
)
|
||||
.map(name => caches.delete(name))
|
||||
)
|
||||
),
|
||||
// Take control of all clients
|
||||
self.clients.claim(),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('message', async event => {
|
||||
if (event.data?.type === 'PREFETCH_ASSETS') {
|
||||
await prefetchAssets();
|
||||
event.ports[0]?.postMessage({ type: 'PREFETCH_COMPLETE' });
|
||||
}
|
||||
});
|
||||
|
||||
+1
-5
@@ -109,11 +109,7 @@
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuex": "~4.1.0",
|
||||
"vuex-router-sync": "6.0.0-rc.1",
|
||||
"wavesurfer.js": "7.8.6",
|
||||
"workbox-cacheable-response": "^7.0.0",
|
||||
"workbox-expiration": "^7.0.0",
|
||||
"workbox-routing": "^7.0.0",
|
||||
"workbox-strategies": "^7.0.0"
|
||||
"wavesurfer.js": "7.8.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@egoist/tailwindcss-icons": "^1.8.1",
|
||||
|
||||
Generated
-51
@@ -247,18 +247,6 @@ importers:
|
||||
wavesurfer.js:
|
||||
specifier: 7.8.6
|
||||
version: 7.8.6
|
||||
workbox-cacheable-response:
|
||||
specifier: ^7.0.0
|
||||
version: 7.4.0
|
||||
workbox-expiration:
|
||||
specifier: ^7.0.0
|
||||
version: 7.4.0
|
||||
workbox-routing:
|
||||
specifier: ^7.0.0
|
||||
version: 7.4.0
|
||||
workbox-strategies:
|
||||
specifier: ^7.0.0
|
||||
version: 7.4.0
|
||||
devDependencies:
|
||||
'@egoist/tailwindcss-icons':
|
||||
specifier: ^1.8.1
|
||||
@@ -2784,9 +2772,6 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb@7.1.1:
|
||||
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
||||
|
||||
idb@8.0.0:
|
||||
resolution: {integrity: sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==}
|
||||
|
||||
@@ -4757,21 +4742,6 @@ packages:
|
||||
resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
workbox-cacheable-response@7.4.0:
|
||||
resolution: {integrity: sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==}
|
||||
|
||||
workbox-core@7.4.0:
|
||||
resolution: {integrity: sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==}
|
||||
|
||||
workbox-expiration@7.4.0:
|
||||
resolution: {integrity: sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==}
|
||||
|
||||
workbox-routing@7.4.0:
|
||||
resolution: {integrity: sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==}
|
||||
|
||||
workbox-strategies@7.4.0:
|
||||
resolution: {integrity: sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7669,8 +7639,6 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb@7.1.1: {}
|
||||
|
||||
idb@8.0.0: {}
|
||||
|
||||
ignore@5.2.4: {}
|
||||
@@ -9823,25 +9791,6 @@ snapshots:
|
||||
dependencies:
|
||||
string-width: 7.2.0
|
||||
|
||||
workbox-cacheable-response@7.4.0:
|
||||
dependencies:
|
||||
workbox-core: 7.4.0
|
||||
|
||||
workbox-core@7.4.0: {}
|
||||
|
||||
workbox-expiration@7.4.0:
|
||||
dependencies:
|
||||
idb: 7.1.1
|
||||
workbox-core: 7.4.0
|
||||
|
||||
workbox-routing@7.4.0:
|
||||
dependencies:
|
||||
workbox-core: 7.4.0
|
||||
|
||||
workbox-strategies@7.4.0:
|
||||
dependencies:
|
||||
workbox-core: 7.4.0
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
@@ -25,6 +25,50 @@ function getCacheVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate asset manifest from Vite build output
|
||||
* Returns array of { url, revision } for JS/CSS files
|
||||
*/
|
||||
function generateAssetManifest() {
|
||||
const manifestPaths = [
|
||||
path.resolve(__dirname, '../public/packs/.vite/manifest.json'),
|
||||
path.resolve(__dirname, '../public/vite-dev/.vite/manifest.json'),
|
||||
];
|
||||
|
||||
let manifestPath = manifestPaths.find(p => fs.existsSync(p));
|
||||
|
||||
if (!manifestPath) {
|
||||
console.warn(
|
||||
'⚠️ No Vite manifest found, skipping asset manifest generation'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`📋 Reading manifest from: ${manifestPath}`);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const assets = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const [, entry] of Object.entries(manifest)) {
|
||||
const file = entry.file;
|
||||
if (!file || seen.has(file)) continue;
|
||||
seen.add(file);
|
||||
|
||||
// Only include JS and CSS files (not images, fonts, etc. - those are cached on demand)
|
||||
if (file.endsWith('.js') || file.endsWith('.css')) {
|
||||
assets.push({
|
||||
url: file,
|
||||
// revision is null because hash is in filename
|
||||
revision: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📦 Found ${assets.length} JS/CSS assets to precache`);
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the service worker
|
||||
*/
|
||||
@@ -62,11 +106,14 @@ async function buildServiceWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Production mode: Build with Workbox runtime
|
||||
try {
|
||||
console.log('📦 Building Workbox runtime bundle...');
|
||||
// Generate asset manifest for precaching
|
||||
const assetManifest = generateAssetManifest();
|
||||
|
||||
// Build the Workbox runtime bundle
|
||||
// Production mode: Build with custom runtime
|
||||
try {
|
||||
console.log('📦 Building service worker runtime...');
|
||||
|
||||
// Build the runtime bundle
|
||||
await build({
|
||||
configFile: false,
|
||||
css: {
|
||||
@@ -81,7 +128,7 @@ async function buildServiceWorker() {
|
||||
'../app/javascript/service-worker/sw-runtime.js'
|
||||
),
|
||||
formats: ['iife'],
|
||||
name: 'WorkboxRuntime',
|
||||
name: 'ServiceWorkerRuntime',
|
||||
fileName: () => 'sw-runtime.js',
|
||||
},
|
||||
outDir: path.resolve(__dirname, '../tmp/sw-build'),
|
||||
@@ -96,12 +143,13 @@ async function buildServiceWorker() {
|
||||
define: {
|
||||
__CACHE_VERSION__: JSON.stringify(cacheVersion),
|
||||
__ASSET_ORIGIN__: JSON.stringify(assetOrigin),
|
||||
__ASSET_MANIFEST__: JSON.stringify(assetManifest),
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
logLevel: 'warn',
|
||||
});
|
||||
|
||||
console.log('✅ Workbox runtime bundle built');
|
||||
console.log('✅ Service worker runtime built');
|
||||
|
||||
// Read the built runtime
|
||||
const runtimeBundle = fs.readFileSync(
|
||||
@@ -128,6 +176,7 @@ async function buildServiceWorker() {
|
||||
const finalServiceWorker = `/* Service Worker for Chatwoot - Generated at build time */
|
||||
/* Cache version: ${cacheVersion} */
|
||||
/* Generated: ${new Date().toISOString()} */
|
||||
/* Precached assets: ${assetManifest.length} */
|
||||
|
||||
${runtimeBundle}
|
||||
|
||||
@@ -141,6 +190,7 @@ ${processedHandlers}
|
||||
);
|
||||
|
||||
console.log('✅ Service worker built successfully at public/sw.js');
|
||||
console.log(` - ${assetManifest.length} assets in precache manifest`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to build service worker:', error);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user