Hold'em dealer: making the table and the bar fit the screen they are on #119
@@ -2692,9 +2692,20 @@ button,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The bar is fixed over the page, so the sidebar needs room to scroll past it. */
|
/* The bar is fixed over the page, so the page needs room to scroll past it.
|
||||||
body.hasActionBar #holdemSidebar {
|
|
||||||
padding-bottom: 9rem;
|
This was 9rem on the sidebar alone, which gave the column of panels somewhere to go
|
||||||
|
and left the table's column with nothing. That is most of the page on a desktop, and
|
||||||
|
it is where the two links under the felt live: measured at 1280x900, the page had 36px
|
||||||
|
of scroll in it against the 94px needed to lift Common rulings out from under a toast,
|
||||||
|
so the focus handler asked for a scroll the page could not make.
|
||||||
|
|
||||||
|
On the whole app rather than one column, and taken from the bar's published height
|
||||||
|
rather than guessed, so it is right in both layouts — 0 when the bar is a rail down
|
||||||
|
the side, which takes no height at all. The fallback keeps the old number for the
|
||||||
|
moment before the first measurement lands. */
|
||||||
|
body.hasActionBar #holdemApp {
|
||||||
|
padding-bottom: calc(var(--actionBarHeight, 9rem) + var(--sp-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- The bar lines up with the table ----------
|
/* ---------- The bar lines up with the table ----------
|
||||||
@@ -4086,12 +4097,25 @@ body.hasActionBar #toastHost {
|
|||||||
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
|
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
|
||||||
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
||||||
transform: translateY(105%);
|
transform: translateY(105%);
|
||||||
transition: transform 0.25s ease;
|
/* A transform moves a thing; it does not put it away. Parked off the bottom of
|
||||||
|
the window the sheet was still in the tab order and still in the accessibility
|
||||||
|
tree, so tabbing through a phone reached its Done button at y=850 on an 844px
|
||||||
|
screen — focused, announced, and impossible to see or scroll to, because a
|
||||||
|
fixed element does not move when the page does.
|
||||||
|
|
||||||
|
visibility does put it away: hidden takes the subtree out of the focus order
|
||||||
|
and off the tree, and unlike display it can be transitioned. The delay is what
|
||||||
|
keeps the slide-out visible — visibility flips at the end of the 0.25s on the
|
||||||
|
way out, and immediately on the way in. */
|
||||||
|
visibility: hidden;
|
||||||
|
transition: transform 0.25s ease, visibility 0s linear 0.25s;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.seatSheetOpen #seatEditorPanel {
|
body.seatSheetOpen #seatEditorPanel {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
|
visibility: visible;
|
||||||
|
transition: transform 0.25s ease, visibility 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatSheetGrip {
|
.seatSheetGrip {
|
||||||
@@ -4707,6 +4731,40 @@ body.hasActionBar #toastHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Focus is brought to rest where it can be seen ----------
|
||||||
|
|
||||||
|
The ring below was the easy half. The hard half is that the bar is fixed over the
|
||||||
|
bottom of the page, so it is not in the flow and the browser does not know it is
|
||||||
|
there: Tab to a control near the bottom and the browser scrolls it flush against the
|
||||||
|
edge of the window — under the bar — and considers the job done. Measured by tabbing
|
||||||
|
the whole page on a 390x844 phone: 16 stops came to rest completely covered and 27
|
||||||
|
more partly. That is WCAG 2.2 SC 2.4.11, which asks that a focused control not be
|
||||||
|
entirely hidden by something the page has put on top of it.
|
||||||
|
|
||||||
|
scroll-padding is the fix, and it goes on the scroll container rather than on the
|
||||||
|
controls: it tells the browser to treat the bottom of the viewport as not there when
|
||||||
|
it scrolls something into view. The number comes from the bar, which already
|
||||||
|
publishes its own height for the toast and the felt, so it cannot drift from the
|
||||||
|
bar's real size the way a guess would — and it is already 0 when the bar is hidden or
|
||||||
|
has become a rail down the side, which is the case scroll-padding-right answers.
|
||||||
|
|
||||||
|
The toast publishes its height into the same sum, because when the browser does
|
||||||
|
scroll it has to clear both.
|
||||||
|
|
||||||
|
This is only half of it, though, and the half that is easy to mistake for all of it.
|
||||||
|
scroll-padding only applies when the browser scrolls at all, and a control already
|
||||||
|
inside the viewport is one it sees no reason to move — a fixed overlay on top of it
|
||||||
|
does not enter into the decision. Measured on a 1280x900 desktop: tabbing to Common
|
||||||
|
rulings while a toast was up left it completely covered, and this rule did nothing
|
||||||
|
about it, because nothing scrolled. The other half is the focusin handler in main.js,
|
||||||
|
which measures the overlap and scrolls by it. */
|
||||||
|
html {
|
||||||
|
scroll-padding-bottom: calc(
|
||||||
|
var(--actionBarHeight, 0px) + var(--toastHeight, 0px) +
|
||||||
|
var(--sp-4) + env(safe-area-inset-bottom, 0px));
|
||||||
|
scroll-padding-right: var(--actionRailW, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Keyboard focus ----------
|
/* ---------- Keyboard focus ----------
|
||||||
Three focus rules existed for the whole tool, so tabbing through it relied on
|
Three focus rules existed for the whole tool, so tabbing through it relied on
|
||||||
whatever ring the browser draws by default — which on these dark, custom-painted
|
whatever ring the browser draws by default — which on these dark, custom-painted
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ const Toast = {
|
|||||||
|
|
||||||
host.appendChild(toast);
|
host.appendChild(toast);
|
||||||
// Next frame, so the entrance transition actually runs.
|
// Next frame, so the entrance transition actually runs.
|
||||||
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
requestAnimationFrame(() => {
|
||||||
|
toast.classList.add("toastVisible");
|
||||||
|
this.publishHeight();
|
||||||
|
});
|
||||||
|
|
||||||
this.timer = setTimeout(
|
this.timer = setTimeout(
|
||||||
() => this.dismiss(),
|
() => this.dismiss(),
|
||||||
@@ -61,6 +64,24 @@ const Toast = {
|
|||||||
}
|
}
|
||||||
const host = document.getElementById("toastHost");
|
const host = document.getElementById("toastHost");
|
||||||
if (host) host.innerHTML = "";
|
if (host) host.innerHTML = "";
|
||||||
|
this.publishHeight();
|
||||||
|
},
|
||||||
|
|
||||||
|
// How much of the bottom of the window this is standing on.
|
||||||
|
//
|
||||||
|
// The toast is fixed, so it is not in the flow and nothing scrolling a control into
|
||||||
|
// view knows it is there — measured on the desktop sweep, tabbing to Common rulings
|
||||||
|
// while a toast was up left the focus ring entirely behind it. The stylesheet adds
|
||||||
|
// this to the bar's own height to work out where a focused control has to stop, so
|
||||||
|
// it is published rather than guessed: the toast is one line or two depending on the
|
||||||
|
// message, and on a narrow screen the same message is taller again.
|
||||||
|
publishHeight() {
|
||||||
|
if (typeof document === "undefined" || !document.documentElement) return;
|
||||||
|
const host = document.getElementById("toastHost");
|
||||||
|
const height = host && host.firstElementChild
|
||||||
|
? Math.round(host.getBoundingClientRect().height)
|
||||||
|
: 0;
|
||||||
|
document.documentElement.style.setProperty("--toastHeight", `${height}px`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -664,6 +664,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Focus that lands underneath something fixed is moved out from under it.
|
||||||
|
//
|
||||||
|
// scroll-padding-bottom in the stylesheet is half of WCAG 2.4.11 and only half. It
|
||||||
|
// works when the browser has to scroll to reach the control at all — then the
|
||||||
|
// padding tells it where to stop. A control the browser already considers visible is
|
||||||
|
// one it will not scroll to at all, and a fixed overlay sitting on top of it does
|
||||||
|
// not change that: measured on a 1280x900 desktop, tabbing to Common rulings while a
|
||||||
|
// toast was up left it 100% covered, and scrollIntoView({block:"nearest"}) moved
|
||||||
|
// nothing, because "nearest" was already satisfied.
|
||||||
|
//
|
||||||
|
// So the overlap is worked out here and the page is scrolled by exactly it. Named
|
||||||
|
// overlays rather than every fixed element on the page, because this runs on every
|
||||||
|
// focus change and walking the whole DOM to find three known things would be work
|
||||||
|
// done ten times a second to reach the same answer. Anything inside one of them is
|
||||||
|
// left alone — the bar's own buttons are meant to be on top of the page.
|
||||||
|
const FIXED_OVERLAYS = "#actionBar, #toastHost, #seatEditorPanel";
|
||||||
|
document.addEventListener("focusin", event => {
|
||||||
|
const el = event.target;
|
||||||
|
if (!el || typeof el.getBoundingClientRect !== "function") return;
|
||||||
|
if (el.closest && el.closest(FIXED_OVERLAYS)) return;
|
||||||
|
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
if (box.width === 0 || box.height === 0) return;
|
||||||
|
|
||||||
|
let lift = 0;
|
||||||
|
document.querySelectorAll(FIXED_OVERLAYS).forEach(node => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
if (node.hidden || style.visibility === "hidden" || style.display === "none") return;
|
||||||
|
if (style.position !== "fixed") return;
|
||||||
|
const over = node.getBoundingClientRect();
|
||||||
|
if (over.width === 0 || over.height === 0) return;
|
||||||
|
// Only what is actually in the way: an overlay off to one side, like the
|
||||||
|
// rail on a phone held sideways, is not covering a control in the middle.
|
||||||
|
if (box.left >= over.right || box.right <= over.left) return;
|
||||||
|
if (box.bottom <= over.top || box.top >= over.bottom) return;
|
||||||
|
lift = Math.max(lift, box.bottom - over.top + 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instant rather than smooth. This is the page getting out of the way of
|
||||||
|
// something the dealer has already arrived at, and animating it would put the
|
||||||
|
// control they are typing into in motion under them.
|
||||||
|
if (lift > 0) window.scrollBy({ top: lift, behavior: "auto" });
|
||||||
|
|
||||||
|
// Scrolling is not always enough. The page only has as much room below its
|
||||||
|
// content as the padding under the app gives it, and that is sized for the bar
|
||||||
|
// — the toast stands on top of the bar, so a control at the very end of the page
|
||||||
|
// can be lifted clear of one and not the other. Measured at 1280x900: 94px of
|
||||||
|
// lift wanted, 26px of page to do it in.
|
||||||
|
//
|
||||||
|
// Sizing the padding for the toast as well would fix it by making the page grow
|
||||||
|
// and shrink by 64px every time anything is announced, which is a worse thing to
|
||||||
|
// do to a dealer than the fault. So the toast gives way instead: it is an
|
||||||
|
// eight-second message about something that has already happened, and it is
|
||||||
|
// standing on a control somebody has just navigated to. Only after the scroll
|
||||||
|
// has been tried, so a toast that is not in the way is left alone.
|
||||||
|
const toast = document.querySelector("#toastHost .toast");
|
||||||
|
if (!toast) return;
|
||||||
|
const after = el.getBoundingClientRect();
|
||||||
|
const over = toast.getBoundingClientRect();
|
||||||
|
const stillCovered = after.left < over.right && after.right > over.left &&
|
||||||
|
after.bottom > over.top && after.top < over.bottom;
|
||||||
|
if (stillCovered) Toast.dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
document.getElementById("resolvePotBtn").addEventListener("click", openPotWizard);
|
||||||
|
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -292,6 +292,44 @@ skipped rather than judged, since it is not in the tree to begin with.
|
|||||||
Removing the one `aria-label` this round added makes it report all five buttons, which is
|
Removing the one `aria-label` this round added makes it report all five buttons, which is
|
||||||
how it was checked.
|
how it was checked.
|
||||||
|
|
||||||
|
## Where the keyboard lands
|
||||||
|
|
||||||
|
Two checks in `uiAudit.js` cover WCAG 2.2 SC 2.4.11, which says a focused control must
|
||||||
|
not be completely hidden by something the page has put on top of it. Tabbing the whole
|
||||||
|
page on a 390x844 phone found **16 stops resting completely covered and 27 more partly**,
|
||||||
|
under the action bar; on the desktop, four more under a toast.
|
||||||
|
|
||||||
|
They are checked as the fix rather than as the symptom, because the symptom needs a
|
||||||
|
keyboard and this file has no automation of its own:
|
||||||
|
|
||||||
|
- **`focusHasRoom()`** — `scroll-padding-bottom` on the scroll container has to reserve at
|
||||||
|
least as much room as the fixed furniture standing at the bottom of the window. Only
|
||||||
|
furniture crossing the middle of the screen counts, or the rail a landscape phone puts
|
||||||
|
down the right-hand side would demand scroll padding the height of the whole window;
|
||||||
|
and sticky elements inside a panel that scrolls on its own are excluded, because
|
||||||
|
scroll-padding on the document would do nothing about them.
|
||||||
|
- **`focusablesReachable()`** — nothing focusable may sit entirely outside the viewport
|
||||||
|
inside a `position: fixed` subtree, since scrolling can never bring it back. The seat
|
||||||
|
editor's bottom sheet was put away with `transform: translateY(105%)`, which moves a
|
||||||
|
thing without putting it away: its Done button sat at y=850 on an 844px screen, still in
|
||||||
|
the tab order and still on the accessibility tree. `visibility: hidden` puts it away.
|
||||||
|
|
||||||
|
### Half a fix looks exactly like a whole one
|
||||||
|
|
||||||
|
`scroll-padding-bottom` alone was written up as the answer and it is only half of it. It
|
||||||
|
applies when the browser has to scroll to reach the control at all; a control the browser
|
||||||
|
already considers visible is one it will not scroll to, and a fixed overlay on top of it
|
||||||
|
does not enter into that decision. Measured: tabbing to Common rulings with a toast up
|
||||||
|
left it 100% covered, and `scrollIntoView({block: "nearest"})` moved nothing, because
|
||||||
|
"nearest" was already satisfied. The sweep had shown clean only because 89 tab presses
|
||||||
|
outlast an eight-second toast.
|
||||||
|
|
||||||
|
So the other half is a `focusin` handler that measures the overlap and scrolls by it —
|
||||||
|
and, when the page has no room left to scroll (26px available against 94px wanted, at
|
||||||
|
1280x900), dismisses the toast instead. Sizing the page's bottom padding for the toast as
|
||||||
|
well would have fixed it by making the page grow and shrink by 64px every time anything
|
||||||
|
was announced, which is worse than the fault.
|
||||||
|
|
||||||
## Why the hand evaluator is tested the way it is
|
## Why the hand evaluator is tested the way it is
|
||||||
|
|
||||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||||
|
|||||||
@@ -105,9 +105,11 @@ describe("the tokens themselves", () => {
|
|||||||
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
||||||
// Published from JS at runtime rather than declared here — see actionBar.js for
|
// Published from JS at runtime rather than declared here — see actionBar.js for
|
||||||
// the first two, render.js for the seat width and the chip's denomination colour,
|
// the first two, render.js for the seat width and the chip's denomination colour,
|
||||||
// which is picked per chip from the amount on it.
|
// which is picked per chip from the amount on it, and feedback.js for the
|
||||||
|
// toast's height, which is one line or two depending on what is being said.
|
||||||
const fromScript = new Set([
|
const fromScript = new Set([
|
||||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||||
|
"--toastHeight",
|
||||||
]);
|
]);
|
||||||
// Defined by the site's own stylesheet, not this one.
|
// Defined by the site's own stylesheet, not this one.
|
||||||
const fromSite = new Set(["--font-mono"]);
|
const fromSite = new Set(["--font-mono"]);
|
||||||
|
|||||||
@@ -96,6 +96,97 @@ const LayoutAudit = {
|
|||||||
return { name: "every control has a name a screen reader can read", offenders };
|
return { name: "every control has a name a screen reader can read", offenders };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Focus has somewhere to land ----
|
||||||
|
|
||||||
|
// Everything fixed to the bottom of the window, as one band. The action bar and the
|
||||||
|
// toast stack, so what matters is the top edge of the highest of them.
|
||||||
|
//
|
||||||
|
// Only things crossing the middle of the screen count. A landscape phone turns the
|
||||||
|
// bar into a rail down the right-hand side, which is bottom-anchored and full height
|
||||||
|
// — measuring that as "furniture at the bottom" would demand scroll padding the
|
||||||
|
// height of the whole window.
|
||||||
|
bottomFurniture() {
|
||||||
|
const middle = innerWidth / 2;
|
||||||
|
// A sticky element is anchored to whatever scrolls it, which is only the window
|
||||||
|
// when nothing between it and the root scrolls. The standings table has a sticky
|
||||||
|
// header inside a panel that scrolls on its own; it comes to rest against the
|
||||||
|
// bottom of the window often enough to be picked up here, and it is not
|
||||||
|
// furniture — scroll-padding on the document would do nothing about it.
|
||||||
|
const anchoredToWindow = node => {
|
||||||
|
if (getComputedStyle(node).position === "fixed") return true;
|
||||||
|
for (let up = node.parentElement; up && up !== document.body; up = up.parentElement) {
|
||||||
|
if (/auto|scroll|overlay/.test(getComputedStyle(up).overflowY)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...document.querySelectorAll("*")].filter(node => {
|
||||||
|
const cs = getComputedStyle(node);
|
||||||
|
if (cs.position !== "fixed" && cs.position !== "sticky") return false;
|
||||||
|
if (cs.visibility === "hidden" || cs.display === "none" || node.hidden) return false;
|
||||||
|
const b = node.getBoundingClientRect();
|
||||||
|
if (b.width === 0 || b.height === 0) return false;
|
||||||
|
if (b.bottom < innerHeight - 2 || b.left > middle || b.right < middle) return false;
|
||||||
|
return anchoredToWindow(node);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// WCAG 2.2 SC 2.4.11: a focused control must not be completely hidden by content the
|
||||||
|
// page put on top of it.
|
||||||
|
//
|
||||||
|
// A fixed bar is not in the flow, so the browser does not know it is there: Tab to a
|
||||||
|
// control near the bottom and it is scrolled flush against the edge of the window,
|
||||||
|
// under the bar, and the browser considers it done. Tabbing the whole page on a
|
||||||
|
// 390x844 phone found 16 stops resting completely covered and 27 more partly.
|
||||||
|
//
|
||||||
|
// Checked as the fix rather than the symptom, because the symptom needs a keyboard
|
||||||
|
// and this file has no automation of its own: scroll-padding-bottom on the scroll
|
||||||
|
// container has to reserve at least as much room as the furniture standing there.
|
||||||
|
focusHasRoom() {
|
||||||
|
const furniture = this.bottomFurniture();
|
||||||
|
if (furniture.length === 0) {
|
||||||
|
return { name: "focus has room under the fixed bar", offenders: [],
|
||||||
|
skipped: "nothing is fixed to the bottom here" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top));
|
||||||
|
const needed = Math.round(innerHeight - highest);
|
||||||
|
const declared = parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom);
|
||||||
|
const reserved = Number.isFinite(declared) ? Math.round(declared) : 0;
|
||||||
|
|
||||||
|
const offenders = reserved >= needed ? [] : [
|
||||||
|
`${furniture.map(n => this.describe(n)).join(" + ")} stands ${needed}px tall, ` +
|
||||||
|
`and scroll-padding-bottom reserves ${reserved}px — a control tabbed to down there ` +
|
||||||
|
`comes to rest ${needed - reserved}px inside it`,
|
||||||
|
];
|
||||||
|
return { name: "focus has room under the fixed bar", offenders };
|
||||||
|
},
|
||||||
|
|
||||||
|
// The other half: a control that scrolling can never reach at all.
|
||||||
|
//
|
||||||
|
// The seat editor is a bottom sheet on a phone, and it was put away with
|
||||||
|
// `transform: translateY(105%)` — which moves it and nothing else. Parked below an
|
||||||
|
// 844px window at y=850, its Done button was still in the tab order and still on the
|
||||||
|
// accessibility tree, and because a fixed element does not move when the page does,
|
||||||
|
// no amount of scrolling could bring it back. visibility:hidden puts it away; a
|
||||||
|
// transform only moves it.
|
||||||
|
focusablesReachable() {
|
||||||
|
const offenders = this.visibleControls()
|
||||||
|
.filter(el => {
|
||||||
|
for (let node = el; node; node = node.parentElement) {
|
||||||
|
if (getComputedStyle(node).position === "fixed") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.filter(el => {
|
||||||
|
const b = el.getBoundingClientRect();
|
||||||
|
return b.top >= innerHeight || b.bottom <= 0 || b.left >= innerWidth || b.right <= 0;
|
||||||
|
})
|
||||||
|
.map(el => `${this.describe(el)} :: fixed and off-screen, so nothing can scroll to it`);
|
||||||
|
|
||||||
|
return { name: "no control is parked where scrolling can't reach it", offenders };
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Touch (see the mobile round) ----
|
// ---- Touch (see the mobile round) ----
|
||||||
|
|
||||||
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
||||||
@@ -1145,6 +1236,8 @@ const LayoutAudit = {
|
|||||||
this.seatsOnTheTable(options.seatSlack),
|
this.seatsOnTheTable(options.seatSlack),
|
||||||
this.dialogActionsReachable(),
|
this.dialogActionsReachable(),
|
||||||
this.controlNames(),
|
this.controlNames(),
|
||||||
|
this.focusHasRoom(),
|
||||||
|
this.focusablesReachable(),
|
||||||
this.touchAction(),
|
this.touchAction(),
|
||||||
this.tapHighlight(),
|
this.tapHighlight(),
|
||||||
this.textSelectable(),
|
this.textSelectable(),
|
||||||
|
|||||||
Reference in New Issue
Block a user