Remove fleet lobby follow notifications
This commit is contained in:
@@ -27,12 +27,6 @@ removal, and restart-recovery state appears alongside affected servers. Fleet
|
|||||||
membership and operation progress refresh every ten seconds, with live server
|
membership and operation progress refresh every ten seconds, with live server
|
||||||
status between refreshes. Failed refreshes keep the last snapshot visible.
|
status between refreshes. Failed refreshes keep the last snapshot visible.
|
||||||
|
|
||||||
**Follow lobby** can notify you when a server is freshly confirmed empty, returns
|
|
||||||
online, or a selected campaign/server operation completes. Watches run while the
|
|
||||||
app is open, continue across navigation, and survive reloads in the same tab.
|
|
||||||
Notifications include a link and remain until dismissed. Watches do not perform
|
|
||||||
server actions or send browser push notifications after the app closes.
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- SvelteKit (adapter-node) + TypeScript
|
- SvelteKit (adapter-node) + TypeScript
|
||||||
|
|||||||
@@ -87,21 +87,3 @@ export function matchesFleet(server: FleetServer, query: string, filter: FleetFi
|
|||||||
...(server.roster ?? []).map(player => player.name)].join(' ').toLocaleLowerCase();
|
...(server.roster ?? []).map(player => player.name)].join(' ').toLocaleLowerCase();
|
||||||
return query.trim().toLocaleLowerCase().split(/\s+/).every(term => text.includes(term));
|
return query.trim().toLocaleLowerCase().split(/\s+/).every(term => text.includes(term));
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LobbyWatch {
|
|
||||||
id: string;
|
|
||||||
server: string;
|
|
||||||
kind: 'empty' | 'online' | 'operation';
|
|
||||||
operationKey?: string;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function watchSatisfied(watch: LobbyWatch, server: FleetServer | undefined,
|
|
||||||
operations: readonly FleetOperation[], now: number): boolean {
|
|
||||||
if (watch.kind === 'operation') {
|
|
||||||
// Disappearance, failure and replacement by another operation are not completion.
|
|
||||||
return operations.some(op => op.key === watch.operationKey && op.state === 'done');
|
|
||||||
}
|
|
||||||
if (!server || !freshServer(server, now)) return false;
|
|
||||||
return watch.kind === 'online' || server.last_players === 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { derived, get, writable } from 'svelte/store';
|
import { derived, writable } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { statusByServer, statusReceivedAt, serverTimeNow, syncServerTime, matchEventsByServer } from './servers.js';
|
import { statusByServer, statusReceivedAt, syncServerTime, matchEventsByServer } from './servers.js';
|
||||||
import { watchSatisfied, type FleetServer, type FleetOperation, type LobbyWatch } from '$lib/fleet-overview.js';
|
import { type FleetServer, type FleetOperation } from '$lib/fleet-overview.js';
|
||||||
import { serverTitle } from '../server-labels.js';
|
|
||||||
import { toast } from '../toast.js';
|
|
||||||
|
|
||||||
export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({
|
export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({
|
||||||
servers: [], operations: [], receivedAt: 0
|
servers: [], operations: [], receivedAt: 0
|
||||||
@@ -11,7 +9,6 @@ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperati
|
|||||||
export const fleetError = writable('');
|
export const fleetError = writable('');
|
||||||
export const fleetRefreshing = writable(false);
|
export const fleetRefreshing = writable(false);
|
||||||
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
|
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
|
||||||
export const lobbyWatches = writable<LobbyWatch[]>([]);
|
|
||||||
|
|
||||||
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer], ([snapshot, live, received, matches]) =>
|
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer], ([snapshot, live, received, matches]) =>
|
||||||
snapshot.servers.map(server => {
|
snapshot.servers.map(server => {
|
||||||
@@ -62,58 +59,18 @@ export function refreshFleet(): Promise<void> {
|
|||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function followLobby(server: string, kind: LobbyWatch['kind'], operation?: FleetOperation): void {
|
|
||||||
const id = `${server}:${kind}:${operation?.key ?? ''}`;
|
|
||||||
lobbyWatches.update(watches => watches.some(watch => watch.id === id) ? watches : [...watches, {
|
|
||||||
id, server, kind, operationKey: operation?.key,
|
|
||||||
href: operation?.href ?? `/servers/${encodeURIComponent(server)}`
|
|
||||||
}]);
|
|
||||||
toast('Watching this lobby while the app is open.');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cancelWatch(id: string): void {
|
|
||||||
lobbyWatches.update(watches => watches.filter(watch => watch.id !== id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let stop: (() => void) | null = null;
|
let stop: (() => void) | null = null;
|
||||||
export function startFleetMonitor(): () => void {
|
export function startFleetMonitor(): () => void {
|
||||||
if (!browser || stop) return () => {};
|
if (!browser || stop) return () => {};
|
||||||
try {
|
|
||||||
const saved: unknown = JSON.parse(sessionStorage.getItem('electric-cherry:lobby-watches') ?? '[]');
|
|
||||||
if (Array.isArray(saved)) lobbyWatches.set(saved.filter((watch): watch is LobbyWatch =>
|
|
||||||
watch && typeof watch.id === 'string' && typeof watch.server === 'string'
|
|
||||||
&& ['empty', 'online', 'operation'].includes(watch.kind)
|
|
||||||
&& typeof watch.href === 'string' && /^\/(servers|campaigns|deployments)\//.test(watch.href)
|
|
||||||
&& (watch.kind !== 'operation' || typeof watch.operationKey === 'string')));
|
|
||||||
} catch { /* Watches still work with session storage disabled. */ }
|
|
||||||
const stopSaving = lobbyWatches.subscribe(watches => {
|
|
||||||
try { sessionStorage.setItem('electric-cherry:lobby-watches', JSON.stringify(watches)); } catch { /* Optional storage. */ }
|
|
||||||
});
|
|
||||||
void refreshFleet();
|
void refreshFleet();
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (document.visibilityState !== 'hidden' || get(lobbyWatches).length) void refreshFleet();
|
if (document.visibilityState !== 'hidden') void refreshFleet();
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
|
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
|
||||||
document.addEventListener('visibilitychange', onFocus);
|
document.addEventListener('visibilitychange', onFocus);
|
||||||
const check = () => {
|
|
||||||
// Never complete a watch using an operation snapshot from a failed refresh.
|
|
||||||
if (get(fleetError)) return;
|
|
||||||
const snapshot = get(fleet);
|
|
||||||
if (!snapshot.receivedAt || Date.now() - snapshot.receivedAt > 30_000) return;
|
|
||||||
const servers = get(fleetServers);
|
|
||||||
for (const watch of get(lobbyWatches)) {
|
|
||||||
if (!watchSatisfied(watch, servers.find(server => server.name === watch.server), snapshot.operations, serverTimeNow())) continue;
|
|
||||||
const message = `${serverTitle(watch.server)} ${watch.kind === 'empty' ? 'is empty' : watch.kind === 'online' ? 'is back online' : 'has finished its operation'}.`;
|
|
||||||
cancelWatch(watch.id);
|
|
||||||
toast(message, { variant: 'success', ttl: 0, href: watch.href });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const unsubscribe = fleetServers.subscribe(check);
|
|
||||||
stop = () => {
|
stop = () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
document.removeEventListener('visibilitychange', onFocus);
|
document.removeEventListener('visibilitychange', onFocus);
|
||||||
unsubscribe();
|
|
||||||
stopSaving();
|
|
||||||
stop = null;
|
stop = null;
|
||||||
};
|
};
|
||||||
return stop;
|
return stop;
|
||||||
|
|||||||
+4
-34
@@ -9,7 +9,7 @@
|
|||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
|
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
|
||||||
import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js';
|
import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js';
|
||||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||||
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
|
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
|
||||||
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
|
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
|
||||||
@@ -119,16 +119,6 @@
|
|||||||
function launch(selection: { game: ZombieGame; slug: string } | null = null) {
|
function launch(selection: { game: ZombieGame; slug: string } | null = null) {
|
||||||
initialGame = selection?.game ?? 't5'; initialMapSlug = selection?.slug ?? ''; deployOpen = true;
|
initialGame = selection?.game ?? 't5'; initialMapSlug = selection?.slug ?? ''; deployOpen = true;
|
||||||
}
|
}
|
||||||
function watch(server: FleetServer, event: Event) {
|
|
||||||
const input = event.currentTarget as HTMLSelectElement;
|
|
||||||
const value = input.value;
|
|
||||||
input.value = '';
|
|
||||||
if (value === 'empty' || value === 'online') followLobby(server.name, value);
|
|
||||||
else {
|
|
||||||
const operation = activeOperations.find(op => op.key === value);
|
|
||||||
if (operation) followLobby(server.name, 'operation', operation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function resetFilters() { query = ''; filter = 'all'; game = ''; }
|
function resetFilters() { query = ''; filter = 'all'; game = ''; }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -183,18 +173,9 @@
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if activeOperations.length || $lobbyWatches.length}
|
{#if activeOperations.length}
|
||||||
<details id="fleet-operations" class="operations" bind:open={operationsOpen}>
|
<details id="fleet-operations" class="operations" bind:open={operationsOpen}>
|
||||||
<summary>Activity · {activeOperations.length} operations{ $lobbyWatches.length ? ` · ${$lobbyWatches.length} lobby watches` : ''}{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
|
<summary>Activity · {activeOperations.length} operations{attentionOperations.length ? ` · ${attentionOperations.length} need attention` : ''}</summary>
|
||||||
{#if $lobbyWatches.length}
|
|
||||||
<div class="watches" aria-label="Lobby watches">
|
|
||||||
<div><strong>Following {$lobbyWatches.length} {$lobbyWatches.length === 1 ? 'condition' : 'conditions'}</strong><span class="hint">Notifications stay active as you navigate. Keep this app open.</span></div>
|
|
||||||
{#each $lobbyWatches as item (item.id)}
|
|
||||||
<button title="Cancel this watch" onclick={() => cancelWatch(item.id)} aria-label="Cancel {item.kind} watch for {serverTitle(item.server)}">{serverTitle(item.server)} · {item.kind === 'empty' ? 'when empty' : item.kind === 'online' ? 'when online' : 'when complete'} ×</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="operation-list">{#each activeOperations as operation (operation.key)}
|
<div class="operation-list">{#each activeOperations as operation (operation.key)}
|
||||||
<a href={operation.href} class:issue={operation.state === 'attention'}><strong>{serverTitle(operation.server)}</strong><span>{operation.label}</span><small>{operation.detail}</small></a>
|
<a href={operation.href} class:issue={operation.state === 'attention'}><strong>{serverTitle(operation.server)}</strong><span>{operation.label}</span><small>{operation.detail}</small></a>
|
||||||
{/each}</div>
|
{/each}</div>
|
||||||
@@ -269,14 +250,6 @@
|
|||||||
|
|
||||||
{#snippet serverActions(server: FleetServer)}
|
{#snippet serverActions(server: FleetServer)}
|
||||||
<button class="pin" class:selected={pinned.includes(server.name)} aria-pressed={pinned.includes(server.name)} aria-label="Pin {serverTitle(server.name)}" onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★ Pinned' : '☆ Pin'}</button>
|
<button class="pin" class:selected={pinned.includes(server.name)} aria-pressed={pinned.includes(server.name)} aria-label="Pin {serverTitle(server.name)}" onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★ Pinned' : '☆ Pin'}</button>
|
||||||
<select class="watch-select" aria-label="Follow {serverTitle(server.name)}" onchange={event => watch(server, event)}>
|
|
||||||
<option value="">Follow lobby…</option>
|
|
||||||
<option value="empty" disabled={freshServer(server, $uiNow) && server.last_players === 0}>Tell me when it’s empty</option>
|
|
||||||
<option value="online" disabled={freshServer(server, $uiNow)}>Tell me when it’s online</option>
|
|
||||||
{#each activeOperations.filter(op => op.server === server.name && (op.kind === 'campaign' || op.kind === 'deployment') && op.state === 'active') as operation (operation.key)}
|
|
||||||
<option value={operation.key}>When complete: {operation.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -319,7 +292,7 @@
|
|||||||
input::placeholder { color: var(--text-secondary); }
|
input::placeholder { color: var(--text-secondary); }
|
||||||
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
|
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.chips button, .view-toggle button, .pin, .watches button { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
|
.chips button, .view-toggle button, .pin { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
|
||||||
.chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; }
|
.chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; }
|
||||||
button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
|
button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
|
||||||
.result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
|
.result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
|
||||||
@@ -334,7 +307,6 @@
|
|||||||
.server-tile :global(.card) { border-radius: 0; }
|
.server-tile :global(.card) { border-radius: 0; }
|
||||||
.tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); }
|
.tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); }
|
||||||
.pin { white-space: nowrap; }
|
.pin { white-space: nowrap; }
|
||||||
.watch-select { font-size: 12px; min-height: 36px; min-width: 0; width: 145px; padding: 6px; }
|
|
||||||
.server-operations { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; font-size: 12px; }
|
.server-operations { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; font-size: 12px; }
|
||||||
.server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; }
|
.server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; }
|
||||||
.server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); }
|
.server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); }
|
||||||
@@ -344,8 +316,6 @@
|
|||||||
.operation-list { display: grid; gap: 10px; margin-top: 16px; }
|
.operation-list { display: grid; gap: 10px; margin-top: 16px; }
|
||||||
.operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: var(--radius-md); color: var(--text-primary); }
|
.operation-list a { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 20px; padding: 12px; text-decoration: none; background: var(--surface); border-radius: var(--radius-md); color: var(--text-primary); }
|
||||||
.operation-list small { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
|
.operation-list small { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||||
.watches { margin-top: 16px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 13px; }
|
|
||||||
.watches > div { margin-right: auto; }
|
|
||||||
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); font-size: 13px; line-height: 1.7; }
|
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); font-size: 13px; line-height: 1.7; }
|
||||||
.error button { margin-left: 8px; }
|
.error button { margin-left: 8px; }
|
||||||
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius-lg); }
|
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius-lg); }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { currentRound, fleetIdentity, matchesFleet, watchSatisfied, type FleetServer, type FleetOperation } from '../../src/lib/fleet-overview.js';
|
import { currentRound, fleetIdentity, matchesFleet, type FleetServer, type FleetOperation } from '../../src/lib/fleet-overview.js';
|
||||||
import { gameEvent } from '../helpers/telemetry.js';
|
import { gameEvent } from '../helpers/telemetry.js';
|
||||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
@@ -54,22 +54,6 @@ describe('Fleet discovery and match freshness', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('one-shot lobby watches', () => {
|
|
||||||
it('requires a fresh zero-player reading, never an absent server or unknown count', () => {
|
|
||||||
const watch = { id: 'empty', server: server.name, kind: 'empty' as const, href: '/' };
|
|
||||||
expect(watchSatisfied(watch, { ...server, last_players: 0 }, [], now)).toBe(true);
|
|
||||||
expect(watchSatisfied(watch, undefined, [], now)).toBe(false);
|
|
||||||
expect(watchSatisfied(watch, { ...server, last_players: null }, [], now)).toBe(false);
|
|
||||||
expect(watchSatisfied(watch, { ...server, last_players: 0, last_seen_at: iso(-46_000) }, [], now)).toBe(false);
|
|
||||||
});
|
|
||||||
it('only completes the watched operation on explicit success', () => {
|
|
||||||
const watch = { id: 'op', server: server.name, kind: 'operation' as const, operationKey: operation.key, href: '/' };
|
|
||||||
expect(watchSatisfied(watch, server, [], now)).toBe(false);
|
|
||||||
expect(watchSatisfied(watch, server, [{ ...operation, state: 'attention' }], now)).toBe(false);
|
|
||||||
expect(watchSatisfied(watch, server, [{ ...operation, key: 'different', state: 'done' }], now)).toBe(false);
|
|
||||||
expect(watchSatisfied(watch, undefined, [{ ...operation, state: 'done' }], now)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('persisted Fleet operations', () => {
|
describe('persisted Fleet operations', () => {
|
||||||
let fixture: Fixture;
|
let fixture: Fixture;
|
||||||
@@ -95,7 +79,7 @@ describe('persisted Fleet operations', () => {
|
|||||||
expect(fleetOperations(now)[0].state).toBe('done');
|
expect(fleetOperations(now)[0].state).toBe('done');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps lifecycle failures actionable until superseded and retains successful completions for watches', async () => {
|
it('keeps lifecycle failures actionable until superseded and retains successful completions', async () => {
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const handle = db();
|
const handle = db();
|
||||||
const insert = handle.prepare("INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'test')");
|
const insert = handle.prepare("INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'test')");
|
||||||
|
|||||||
Reference in New Issue
Block a user