Virtual gearing, trainer-speed blend, and cadence decode
Gears are expressed as an offset to the commanded gradient, leaving the physics on the route's true gradient so shifting changes effort, not speed. Neutral gear commands exactly the route gradient, so an un-shifted ride is unchanged. Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded against captured frames. The undeclared FTMS trailing bytes were ruled out: wheel RPM restated at a fixed 73.8x speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,36 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!--
|
||||
The Zwift Click is deliberately not in the device list below: that list is
|
||||
FTMS trainers, and a controller is a different kind of thing with a
|
||||
different failure mode (it sleeps in seconds and must be woken by hand).
|
||||
-->
|
||||
<div class="gate">
|
||||
<span class="dot {app.controller?.connected ? 'tone-ok' : 'tone-warn'}"></span>
|
||||
<span>
|
||||
{#if app.controller?.connected}
|
||||
Zwift Click connected{app.controller.batteryPercent != null
|
||||
? ` — battery ${app.controller.batteryPercent}%`
|
||||
: ''}. Paddles shift; the D-pad drives the UI.
|
||||
{:else if app.controller?.error}
|
||||
Controller: {app.controller.error}
|
||||
{:else}
|
||||
No controller. <strong>Press a button on the Click first</strong> — it only advertises
|
||||
while awake.
|
||||
{/if}
|
||||
</span>
|
||||
{#if app.controller?.connected}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
|
||||
Disconnect
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.connectController())}>
|
||||
Connect Click
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !trainerReady}
|
||||
<div class="gate">
|
||||
<span class="dot tone-warn"></span>
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
|
||||
const ride = $derived(app.ride);
|
||||
const running = $derived(ride?.status === 'running');
|
||||
/**
|
||||
* Before a ride starts, the gradient trim, the lap marker and End have
|
||||
* nothing to act on. The ride screen is showing a large Start button at that
|
||||
* point, and nine similar-looking buttons beside it only make it harder to
|
||||
* find.
|
||||
*/
|
||||
const started = $derived(ride?.status === 'running' || ride?.status === 'paused');
|
||||
|
||||
let flash = $state<string | null>(null);
|
||||
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -26,35 +33,39 @@
|
||||
</script>
|
||||
|
||||
<div class="bar">
|
||||
<div class="group">
|
||||
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(-0.5)}>
|
||||
<span class="glyph">−</span> Grade <span class="kbd">↓</span>
|
||||
</button>
|
||||
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(0.5)}>
|
||||
<span class="glyph">+</span> Grade <span class="kbd">↑</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn ghost"
|
||||
class:lit={flash === 'gradient-reset'}
|
||||
onclick={() => app.run(() => api.resetGradient())}
|
||||
>
|
||||
Zero <span class="kbd">0</span>
|
||||
</button>
|
||||
</div>
|
||||
{#if started}
|
||||
<div class="group">
|
||||
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(-0.5)}>
|
||||
<span class="glyph">−</span> Grade <span class="kbd">↓</span>
|
||||
</button>
|
||||
<button class="btn" class:lit={flash === 'gradient'} onclick={() => grade(0.5)}>
|
||||
<span class="glyph">+</span> Grade <span class="kbd">↑</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn ghost"
|
||||
class:lit={flash === 'gradient-reset'}
|
||||
onclick={() => app.run(() => api.resetGradient())}
|
||||
>
|
||||
Zero <span class="kbd">0</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="group">
|
||||
<button class="btn" class:lit={flash === 'mode'} onclick={() => app.run(() => api.cycleMode())}>
|
||||
Mode: {MODE_LABEL[ride?.mode ?? 'ManualGrade']} <span class="kbd">M</span>
|
||||
</button>
|
||||
<button class="btn ghost" onclick={() => (app.showProfiles = true)}>
|
||||
Profile <span class="kbd">P</span>
|
||||
<button class="btn" onclick={() => (app.showProfiles = true)}>
|
||||
Route <span class="kbd">P</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="group right">
|
||||
<button class="btn" class:lit={flash === 'lap'} onclick={() => app.run(() => api.markLap())}>
|
||||
Lap {ride?.lap ?? 1} <span class="kbd">L</span>
|
||||
</button>
|
||||
{#if started}
|
||||
<button class="btn" class:lit={flash === 'lap'} onclick={() => app.run(() => api.markLap())}>
|
||||
Lap {ride?.lap ?? 1} <span class="kbd">L</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if running}
|
||||
<button class="btn" class:lit={flash === 'toggle-pause'} onclick={() => app.run(() => api.togglePause())}>
|
||||
Pause <span class="kbd">␣</span>
|
||||
@@ -64,7 +75,9 @@
|
||||
{ride?.status === 'paused' ? 'Resume' : 'Start ride'} <span class="kbd">␣</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
|
||||
{#if started}
|
||||
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
|
||||
{/if}
|
||||
<button class="btn ghost" onclick={() => (app.showHelp = !app.showHelp)} title="Keyboard shortcuts">
|
||||
<span class="kbd">?</span>
|
||||
</button>
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Keyboard shortcuts (FR-3.19). These exist because there is no physical
|
||||
* controller in this phase — and because if a Click pod dies mid-ride, the
|
||||
* keyboard is the only way to keep the session going.
|
||||
* Keyboard shortcuts (FR-3.19), and the Zwift Click buttons that mirror them.
|
||||
* The keyboard remains the fallback: if a Click pod dies or its battery goes
|
||||
* mid-ride, it is the only way to keep the session going.
|
||||
*/
|
||||
import { app } from '../lib/app.svelte';
|
||||
|
||||
/** Kept beside the keyboard list so the two cannot drift apart on screen —
|
||||
* they already share one implementation in `App.svelte`. */
|
||||
const CONTROLLER: [string, string][] = [
|
||||
['+ / −', 'Shift a gear: ±10 W, or one resistance level'],
|
||||
['D-pad ↑ / ↓', 'Gradient +0.5% / −0.5%'],
|
||||
['D-pad ← / →', 'Device screen / ride screen'],
|
||||
['A', 'Pause / resume'],
|
||||
['B', 'Insert lap marker'],
|
||||
['Y', 'Cycle control mode'],
|
||||
['Z', 'Profiles and routes'],
|
||||
];
|
||||
|
||||
const BINDINGS: [string, string][] = [
|
||||
['↑ / ↓', 'Gradient +0.5% / −0.5%'],
|
||||
['Shift + ↑ / ↓', 'Gradient ±2% (coarse)'],
|
||||
@@ -39,10 +51,28 @@
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
<p class="note">
|
||||
Every one of these has an on-screen equivalent in the control bar, and each will map to a
|
||||
Zwift Click button once the controller client lands.
|
||||
</p>
|
||||
<h2>Zwift Click</h2>
|
||||
<dl>
|
||||
{#each CONTROLLER as [key, what]}
|
||||
<div>
|
||||
<dt><span class="kbd">{key}</span></dt>
|
||||
<dd>{what}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{#if app.controller?.connected}
|
||||
<p class="note">
|
||||
Controller connected{app.controller.batteryPercent != null
|
||||
? ` — battery ${app.controller.batteryPercent}%`
|
||||
: ''}.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="note">
|
||||
No controller connected. A Click only advertises after a button press, so wake it and
|
||||
connect from the device screen.
|
||||
</p>
|
||||
{/if}
|
||||
<p class="note">Every one of these has an on-screen equivalent in the control bar.</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -30,6 +30,40 @@
|
||||
const ride = $derived(app.ride);
|
||||
const profile = $derived(ride?.profile ?? null);
|
||||
|
||||
/**
|
||||
* The trainer chip (FR-1.8, FR-9.3). The rider must never be left reading
|
||||
* zeros without being told why, so every non-controlling state gets words.
|
||||
*/
|
||||
const trainerChip = $derived.by(() => {
|
||||
if (ride?.source === 'mock') {
|
||||
return { tone: 'tone-warn', label: 'Simulated — no trainer' };
|
||||
}
|
||||
const t = ride?.trainer;
|
||||
if (!t) return null;
|
||||
const state = t.state;
|
||||
if (typeof state === 'object' && 'Lost' in state) {
|
||||
return { tone: 'tone-bad', label: `Trainer unavailable — ${state.Lost.reason}` };
|
||||
}
|
||||
switch (state) {
|
||||
case 'Idle':
|
||||
return { tone: 'tone-warn', label: 'No trainer — open Devices to connect' };
|
||||
case 'Scanning':
|
||||
return { tone: 'tone-warn', label: 'Scanning for the trainer…' };
|
||||
case 'Connecting':
|
||||
return { tone: 'tone-warn', label: 'Connecting to the trainer…' };
|
||||
case 'Reconnecting':
|
||||
return { tone: 'tone-warn', label: 'Trainer lost — reconnecting' };
|
||||
case 'Connected':
|
||||
return { tone: 'tone-warn', label: 'Connected, control not acquired' };
|
||||
case 'Controlling':
|
||||
if (t.stale) return { tone: 'tone-warn', label: 'Trainer silent — pedal to wake it' };
|
||||
if (t.error) return { tone: 'tone-bad', label: t.error };
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const gradient = $derived(snap?.gradient_pct ?? 0);
|
||||
const gradeColour = $derived(
|
||||
gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
|
||||
@@ -77,6 +111,18 @@
|
||||
return d.distanceRemainingM;
|
||||
});
|
||||
|
||||
/**
|
||||
* FR-7.5 — the trainer's own speed reading is diagnostic, so it sits beside
|
||||
* the virtual speed rather than replacing it. A trainer that reports nothing
|
||||
* (or, as on the D100, in units we have not confirmed) is then visible as a
|
||||
* mismatch instead of silently absent.
|
||||
*/
|
||||
const speedSub = $derived.by(() => {
|
||||
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
|
||||
const trainer = snap?.telemetry.speed_kph;
|
||||
return trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now;
|
||||
});
|
||||
|
||||
const statusChip = $derived.by(() => {
|
||||
switch (ride?.status) {
|
||||
case 'running':
|
||||
@@ -89,27 +135,66 @@
|
||||
return { label: 'Ready', tone: 'tone-idle' };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A ride that has not started yet. Before the first pedal stroke almost
|
||||
* everything on this screen reads zero, so the screen's job is not to show
|
||||
* numbers — it is to show the one action that matters.
|
||||
*/
|
||||
const preRide = $derived(ride?.status !== 'running' && ride?.status !== 'paused');
|
||||
|
||||
/**
|
||||
* The rider is looking at invented data. This is never inferred from a
|
||||
* missing trainer — the app shows zeros for that — it is only ever true
|
||||
* because someone asked for it with `BIKECONTROL_DEMO`/`BIKECONTROL_MOCK`.
|
||||
* It still gets a banner, because a session you cannot tell from a real one
|
||||
* is worse than no session at all.
|
||||
*/
|
||||
const simulated = $derived(ride?.source === 'mock');
|
||||
|
||||
/** Route length and climbing, said plainly, so "what is loaded" is obvious. */
|
||||
const routeSummary = $derived.by(() => {
|
||||
if (!profile) return null;
|
||||
const bits: string[] = [];
|
||||
if (profile.totalMetres) bits.push(`${(profile.totalMetres / 1000).toFixed(1)} km`);
|
||||
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
|
||||
if (profile.totalAscentM != null) bits.push(`${profile.totalAscentM.toFixed(0)} m up`);
|
||||
if (profile.looping) bits.push('loops');
|
||||
return bits.join(' · ');
|
||||
});
|
||||
|
||||
const openRoutes = () => (app.showProfiles = true);
|
||||
</script>
|
||||
|
||||
<div class="ride">
|
||||
<div class="ride" class:simulated>
|
||||
{#if simulated}
|
||||
<!-- Not a chip, not a toast: a rider must not be able to finish a session
|
||||
and only then find out none of it was real. -->
|
||||
<div class="sim-banner">
|
||||
<strong>Simulated ride</strong>
|
||||
<span>Power, speed and distance are fabricated. No trainer is being read.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Header: what is loaded, what mode, what target (FR-9.8). -->
|
||||
<header>
|
||||
<div class="who">
|
||||
<h1>{profile?.name ?? 'No route'}</h1>
|
||||
{#if profile?.description}
|
||||
<p>{profile.description}</p>
|
||||
<h1>{profile?.name ?? 'No route loaded'}</h1>
|
||||
{#if routeSummary}
|
||||
<p>{routeSummary}{profile?.description ? ` — ${profile.description}` : ''}</p>
|
||||
{:else}
|
||||
<p>Manual control — load a profile to ride terrain.</p>
|
||||
<p>Manual control — choose a route to ride real terrain.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
{#if ride?.source === 'mock'}
|
||||
<span class="chip tone-warn"><span class="dot"></span>Simulated — no trainer</span>
|
||||
{#if trainerChip}
|
||||
<span class="chip {trainerChip.tone}"><span class="dot"></span>{trainerChip.label}</span>
|
||||
{/if}
|
||||
<span class="chip {statusChip.tone}"><span class="dot"></span>{statusChip.label}</span>
|
||||
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
|
||||
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
|
||||
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
|
||||
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -119,6 +204,33 @@
|
||||
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
||||
</section>
|
||||
|
||||
{#if preRide}
|
||||
<!-- The whole screen before a ride begins: one obvious action. -->
|
||||
<section class="launch">
|
||||
{#if profile}
|
||||
<button class="start" onclick={() => app.run(() => api.start())}>
|
||||
{ride?.status === 'finished' ? 'Ride again' : 'Start ride'}
|
||||
<span class="kbd">␣</span>
|
||||
</button>
|
||||
<div class="launch-aside">
|
||||
<span class="launch-route">{profile.name}</span>
|
||||
<span class="launch-sub">{routeSummary}</span>
|
||||
<button class="btn ghost" onclick={openRoutes}>Choose a different route</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="start" onclick={openRoutes}>Choose a route</button>
|
||||
<div class="launch-aside">
|
||||
<span class="launch-sub">
|
||||
Pick a bundled route or open your own GPX. Or start now and ride on manual gradient.
|
||||
</span>
|
||||
<button class="btn ghost" onclick={() => app.run(() => api.start())}>
|
||||
Start without a route
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Primary readouts. -->
|
||||
<section class="primary">
|
||||
<Readout
|
||||
@@ -142,7 +254,7 @@
|
||||
value={num(d?.smoothedSpeedKph ?? 0, 1)}
|
||||
unit="km/h"
|
||||
size="big"
|
||||
sub={`now ${num(snap?.virtual_speed_kph ?? 0, 1)}`}
|
||||
sub={speedSub}
|
||||
/>
|
||||
<Readout
|
||||
label="Gradient"
|
||||
@@ -197,6 +309,8 @@
|
||||
size="small"
|
||||
/>
|
||||
<Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
|
||||
<!-- An estimate, not a measurement — see `bikecontrol_core::energy`. -->
|
||||
<Readout label="Burned" value={num(d?.caloriesKcal ?? 0, 0)} unit="kcal" size="small" />
|
||||
<div class="spacer"></div>
|
||||
<div class="charts">
|
||||
<div class="chart">
|
||||
@@ -226,11 +340,56 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* A column, not a fixed grid.
|
||||
*
|
||||
* This used to be `grid-template-rows` with pixel minimums that together
|
||||
* exceeded a short window. When they did, the tracks overflowed and the
|
||||
* chart section was painted straight over the control bar — and because
|
||||
* uPlot positions its canvas and its `.u-over` overlay, both of which are
|
||||
* `position: relative/absolute`, they painted *above* the unpositioned
|
||||
* buttons and swallowed every click on them. The Start ride button was
|
||||
* visible, looked enabled, and did nothing.
|
||||
*
|
||||
* Flex items cannot overlap, the flexible sections absorb the slack, and
|
||||
* `overflow: hidden` on the chart area means a canvas that has not yet been
|
||||
* resized cannot escape it either. The control bar keeps its own stacking
|
||||
* context as a final guarantee.
|
||||
*/
|
||||
.ride {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(150px, 1fr) auto auto minmax(190px, 0.95fr) auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ride > :global(*) {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sim-banner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.55rem var(--edge);
|
||||
background: var(--warn);
|
||||
color: #1a1400;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.sim-banner strong {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* A hairline of the same warning colour all the way round the ride, so the
|
||||
state is legible from the corner of the eye at any scroll position. */
|
||||
.ride.simulated {
|
||||
box-shadow: inset 0 0 0 2px var(--warn);
|
||||
}
|
||||
|
||||
header {
|
||||
@@ -283,8 +442,78 @@
|
||||
}
|
||||
|
||||
.route {
|
||||
min-height: 0;
|
||||
flex: 2 1 0;
|
||||
min-height: 130px;
|
||||
padding: 0 var(--edge);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- pre-ride launch panel ---------------------------------------- */
|
||||
|
||||
.launch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
flex-wrap: wrap;
|
||||
padding: 1.1rem var(--edge);
|
||||
margin: 0.9rem var(--edge) 0.2rem;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--bg-lift);
|
||||
}
|
||||
|
||||
/* The single most important control on the screen before a ride, and it must
|
||||
look like it. */
|
||||
.start {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.7em;
|
||||
padding: 0.85em 2em;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--route);
|
||||
color: #04121a;
|
||||
font-size: clamp(1.1rem, 1.5vw, 1.45rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
transform 90ms ease;
|
||||
}
|
||||
|
||||
.start:hover {
|
||||
background: #6cdcff;
|
||||
}
|
||||
|
||||
.start:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.start :global(.kbd) {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: #04121a;
|
||||
}
|
||||
|
||||
.launch-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.launch-route {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.launch-sub {
|
||||
font-size: 0.88rem;
|
||||
color: var(--ink-dim);
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.launch-aside .btn {
|
||||
margin-top: 0.3rem;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.primary {
|
||||
@@ -304,13 +533,16 @@
|
||||
}
|
||||
|
||||
.effort {
|
||||
flex: 1 1 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, auto)) 1fr;
|
||||
grid-template-columns: repeat(7, minmax(0, auto)) 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--gap);
|
||||
padding: 0.9rem var(--edge) 0.4rem;
|
||||
min-height: 0;
|
||||
/* Nothing inside may be painted outside: see the note on `.ride`. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@@ -319,10 +551,16 @@
|
||||
|
||||
.charts {
|
||||
grid-column: 1 / -1;
|
||||
/* `align-items: start` above would otherwise leave this at its content
|
||||
height — which is whatever size uPlot happened to pick — instead of the
|
||||
height the track actually has. That is what let the canvas grow past the
|
||||
bottom of the section. */
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: var(--gap);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart {
|
||||
@@ -330,6 +568,7 @@
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 0.15rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
|
||||
Reference in New Issue
Block a user