feat: give the app the five breakpoints, and let the screen margin follow them
Phase 6, steps 1 and 2. The app had no notion of window width at all -- two
`BoxWithConstraints` in 30,000 lines of UI, both inside a view model -- so every
layout decision in it was made once, for a phone, and then rendered unchanged
into a 1800dp desktop window.
**The dependency question the plan asked to settle first.** `material3-adaptive`
publishes multiplatform under `org.jetbrains.compose.material3.adaptive`, with
android, desktop and ios variants; the ios ones carry `ios_arm64` and
`ios_simulator_arm64` attributes despite the `uikit*` artifact names, so the
targets this project declares on a mac resolve. Version **1.2.0**, not the newer
1.3.0-beta02, because that is the version the pinned material3 itself resolves:
`material3-adaptive-navigation-suite:1.10.0-alpha05` names `adaptive:1.2.0` in
its pom, and 1.3.0 would pull window-core 1.5.0 in beside the 1.4.0 the pinned
material3 compiled against. Nothing is lost by staying: 1.2.0 already computes
the large and extra-large breakpoints through `supportLargeAndXLargeWidth`, and
carries `ListDetailPaneScaffold` for the pane work. So steps 3-4 can use the
library scaffolds rather than a hand-rolled equivalent.
**`Breakpoint`** is the five-value enum -- compact / medium / expanded / large /
extra-large at 0 / 600 / 840 / 1200 / 1600dp -- with `ofWidth` as a pure function
so the thresholds are assertable without a Compose runtime. `TorchTheme`
classifies once and provides `LocalBreakpoint`, so no two screens can disagree
about the window they are both in.
It reads `currentWindowDpSize()` rather than `currentWindowAdaptiveInfo()`.
The latter also computes a `Posture` from the platform's fold state, which on
android reaches for `WindowInfoTracker` and an activity; this call sits in
`TorchTheme`, which wraps all 51 `@Preview` bodies in the tree, and a preview
context is not an activity. The pane scaffolds ask for posture themselves, at
the one place a fold changes the answer.
**Spacing now adapts, and exactly one value moves.** M3 publishes a margin per
breakpoint -- 16dp compact, 24dp everywhere wider -- and publishes nothing else
that varies with window width. The scale itself is absolute: `space200` is 16dp
on a phone and 16dp on a desktop, and what adapts is which token a job reaches
for, not the token. So `screenMargin` goes 16 -> 24 at medium and holds there,
and `containerPadding`, `itemGap` and the rest do not move -- a card does not
become a different component because the window grew. Widening all of them is
the "everything breathes on a big screen" instinct, and it reads as a zoomed
phone rather than as a layout. A test asserts the non-movement, because that is
the edit a later reviewer would wave through.
Mechanically this made the eight semantic names constructor parameters instead
of `get()`s over the scale, so a breakpoint can reassign one without moving the
stop underneath it. Kotlin resolves a default expression against the parameters
before it, so each still reads its stop by name and still follows it when the
scale is overridden -- phase 2's `Spacing(space200 = 24.dp)` assertion holds
unchanged. The two instances are singletons because `LocalSpacing` is a
`staticCompositionLocalOf` and invalidates on identity, not equality.
**A test found a real defect while being written.** `ofWidth` was
`entries.last { width >= it.minWidth }`, which throws `NoSuchElementException`
below 0dp. A desktop window reports a zero size for the frame before its first
layout pass, and this is called from the theme on every composition, so the
crash would have arrived on a resize rather than on anything a user did. Now
total.
`:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` both
green; 28 theme tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,7 @@ kotlin {
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material3.adaptive)
|
||||
implementation (libs.compose.material.icons.core)
|
||||
implementation (libs.compose.material.icons.extended)
|
||||
implementation(libs.compose.ui)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowDpSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* M3's five window width breakpoints.
|
||||
*
|
||||
* Renamed from "window size class" in the May 2026 revision, which also grew the set from
|
||||
* three to five: [Large] and [ExtraLarge] were split off the old expanded class because a
|
||||
* 1600dp window and an 850dp one want different numbers of panes. Values are from
|
||||
* m3.material.io/foundations/layout/applying-layout/window-size-classes, read September
|
||||
* 2026.
|
||||
*
|
||||
* | breakpoint | width | panes | navigation |
|
||||
* |--------------|-------------|------------------------|-------------------------|
|
||||
* | [Compact] | under 600dp | 1 | navigation bar |
|
||||
* | [Medium] | 600–839dp | 1 recommended | collapsed rail |
|
||||
* | [Expanded] | 840–1199dp | 2 recommended | collapsed/expanded rail |
|
||||
* | [Large] | 1200–1599dp | 2 recommended | expanded rail |
|
||||
* | [ExtraLarge] | 1600dp+ | up to 3 | expanded rail |
|
||||
*
|
||||
* The classification is on the **window**, not on the composable being measured. That is
|
||||
* the distinction between this and `BoxWithConstraints`: a pane 300dp wide inside a
|
||||
* 1400dp window is still in a large layout, and should not start behaving like a phone.
|
||||
* Anything wanting the local constraints should still measure them.
|
||||
*
|
||||
* @property minWidth the narrowest window that falls in this breakpoint.
|
||||
*/
|
||||
enum class Breakpoint(val minWidth: Dp) {
|
||||
Compact(0.dp),
|
||||
Medium(600.dp),
|
||||
Expanded(840.dp),
|
||||
Large(1200.dp),
|
||||
ExtraLarge(1600.dp);
|
||||
|
||||
/**
|
||||
* `true` when this breakpoint is [other] or anything wider.
|
||||
*
|
||||
* The comparison call sites want almost always. Enum ordering already answers it, but
|
||||
* `breakpoint >= Breakpoint.Expanded` reads as a size comparison on a width and is
|
||||
* one edit away from being wrong if a breakpoint is ever inserted; this says what it
|
||||
* means.
|
||||
*/
|
||||
fun isAtLeast(other: Breakpoint): Boolean = ordinal >= other.ordinal
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The breakpoint a window of [width] falls in.
|
||||
*
|
||||
* Ranges are half-open on the upper bound -- exactly 600dp is [Medium], not
|
||||
* [Compact] -- which is how M3 states them and how `WindowSizeClass` computes
|
||||
* them.
|
||||
*
|
||||
* Total, including for a width below [Compact.minWidth]. This is called from
|
||||
* [TorchTheme] on every composition, and a desktop window reports a zero size for
|
||||
* the frame before its first layout pass; throwing there would take the app down
|
||||
* on a resize rather than on anything a user did.
|
||||
*/
|
||||
fun ofWidth(width: Dp): Breakpoint =
|
||||
entries.lastOrNull { width >= it.minWidth } ?: Compact
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The breakpoint the current window is in.
|
||||
*
|
||||
* Provided by [TorchTheme] as [LocalBreakpoint], so screens read
|
||||
* `MaterialTheme.breakpoint` rather than calling this. It is public because the two
|
||||
* entry points that compose above the theme -- the desktop passphrase gate, and any
|
||||
* future splash -- have nowhere else to get it.
|
||||
*
|
||||
* **Why the window size and not `currentWindowAdaptiveInfo()`.** The latter also computes
|
||||
* a [androidx.compose.material3.adaptive.Posture] from the platform's fold state, which
|
||||
* on android means reaching for `WindowInfoTracker` and an `Activity`. This is called
|
||||
* from [TorchTheme], which wraps every `@Preview` in the tree, and a preview context is
|
||||
* not an activity. `currentWindowDpSize()` is `LocalWindowInfo` and `LocalDensity` and
|
||||
* nothing else, so it answers everywhere. The pane scaffolds ask for posture themselves,
|
||||
* where a fold genuinely changes the answer.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun currentBreakpoint(): Breakpoint = Breakpoint.ofWidth(currentWindowDpSize().width)
|
||||
|
||||
/**
|
||||
* Defaults to [Breakpoint.Compact] rather than throwing, because that is the layout every
|
||||
* screen in this app was written against: a composable that never reaches a [TorchTheme]
|
||||
* should render as it did before breakpoints existed, not fail.
|
||||
*/
|
||||
val LocalBreakpoint = staticCompositionLocalOf { Breakpoint.Compact }
|
||||
|
||||
/** `MaterialTheme.breakpoint`, to match `MaterialTheme.spacing` and `MaterialTheme.colorScheme`. */
|
||||
val MaterialTheme.breakpoint: Breakpoint
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalBreakpoint.current
|
||||
@@ -19,11 +19,10 @@ import androidx.compose.ui.unit.dp
|
||||
* m3.material.io/m3/pages/spacing/tokens, read September 2026; see
|
||||
* docs/material-design-conformance.md for the full table.
|
||||
*
|
||||
* **Why a `data class` and not constants.** Nothing scales today, but two things are
|
||||
* coming that need to: spacing adapts across breakpoints, and M3 has a density setting
|
||||
* for data-heavy views. Both are a matter of providing a different [Spacing] instance
|
||||
* rather than of touching a call site, and that is only true if the values arrive through
|
||||
* the local. A file of top-level `val`s would read the same and adapt to nothing.
|
||||
* **Why a `data class` and not constants.** So that a different instance can be provided
|
||||
* without touching a call site, which is what [spacingFor] now does per [Breakpoint] and
|
||||
* what M3's density setting for data-heavy views would do next. A file of top-level
|
||||
* `val`s would read the same at the call site and adapt to nothing.
|
||||
*/
|
||||
@Immutable
|
||||
data class Spacing(
|
||||
@@ -45,7 +44,7 @@ data class Spacing(
|
||||
val space700: Dp = 56.dp,
|
||||
val space800: Dp = 64.dp,
|
||||
val space900: Dp = 72.dp,
|
||||
) {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Semantic names
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -65,30 +64,64 @@ data class Spacing(
|
||||
// aren't uniform, and require more tokens" -- so there is exactly one margin here,
|
||||
// for the screen edge, and everything else is padding or a gap.
|
||||
|
||||
// They are constructor parameters rather than `get()`s over the scale so that a
|
||||
// breakpoint can reassign one without moving the stop underneath it. That direction
|
||||
// matters: M3's spacing tokens are absolute values that do not change with window
|
||||
// width -- `space200` is 16dp on a phone and 16dp on a desktop -- and what adapts is
|
||||
// which token a given job reaches for. A wider window takes a wider screen margin, it
|
||||
// does not take a wider 16.
|
||||
//
|
||||
// Kotlin resolves a default expression against the parameters before it, so each of
|
||||
// these still reads its stop by name and follows it when the scale itself is
|
||||
// overridden. `Spacing(space200 = 24.dp)` still moves `screenMargin`.
|
||||
|
||||
/** Screen edge to content. The one margin; everything inside a screen is padding or a gap. */
|
||||
val screenMargin: Dp get() = space200
|
||||
val screenMargin: Dp = space200,
|
||||
|
||||
/** Inside a card, dialog, sheet or list row: container edge to its content. */
|
||||
val containerPadding: Dp get() = space200
|
||||
val containerPadding: Dp = space200,
|
||||
|
||||
/** Inside a compact container -- a chip, a badge, a dense row. */
|
||||
val compactPadding: Dp get() = space100
|
||||
val compactPadding: Dp = space100,
|
||||
|
||||
/** Between two elements that belong to the same thought: a label and its value. */
|
||||
val relatedGap: Dp get() = space50
|
||||
val relatedGap: Dp = space50,
|
||||
|
||||
/** The default gap between items in a list or column. */
|
||||
val itemGap: Dp get() = space100
|
||||
val itemGap: Dp = space100,
|
||||
|
||||
/** Between one group of content and the next within a screen. */
|
||||
val sectionGap: Dp get() = space300
|
||||
val sectionGap: Dp = space300,
|
||||
|
||||
/** Around a lone element that needs to stand apart -- an empty state, a hero action. */
|
||||
val emphasisGap: Dp get() = space500
|
||||
val emphasisGap: Dp = space500,
|
||||
|
||||
/** Between adjacent touch targets, which M3 asks to be at least 8dp apart. */
|
||||
val targetGap: Dp get() = space100
|
||||
}
|
||||
val targetGap: Dp = space100,
|
||||
)
|
||||
|
||||
/**
|
||||
* The spacing a window at [breakpoint] should use.
|
||||
*
|
||||
* Exactly one value moves, and that is not an oversight. M3 publishes a margin per
|
||||
* breakpoint -- 16dp compact, 24dp everywhere wider -- and publishes nothing else that
|
||||
* varies with window width: padding inside a card and the gap between two list rows are
|
||||
* component decisions, and a card does not become a different component because the
|
||||
* window grew. Widening them all would be the "everything breathes on a big screen"
|
||||
* instinct, which reads as a zoomed phone rather than as a layout.
|
||||
*
|
||||
* What actually fills a wide window is a second pane and a bounded measure, not fatter
|
||||
* gaps. Those are layout, and they live in `AdaptiveContent` rather than here.
|
||||
*/
|
||||
fun spacingFor(breakpoint: Breakpoint): Spacing =
|
||||
if (breakpoint == Breakpoint.Compact) CompactSpacing else MediumAndWiderSpacing
|
||||
|
||||
// Held as singletons rather than built per call. `LocalSpacing` is a
|
||||
// `staticCompositionLocalOf`, so a provider that hands it a fresh but equal instance on
|
||||
// every recomposition would restart every composition reading it; `Spacing` is a data
|
||||
// class, but static locals compare by identity when deciding whether to invalidate.
|
||||
private val CompactSpacing = Spacing()
|
||||
private val MediumAndWiderSpacing = Spacing(screenMargin = 24.dp)
|
||||
|
||||
val LocalSpacing = staticCompositionLocalOf { Spacing() }
|
||||
|
||||
|
||||
@@ -505,12 +505,17 @@ fun TorchTheme(
|
||||
val colorScheme = dynamicColorScheme(darkTheme, dynamicColor)
|
||||
?: appColorScheme(darkTheme, contrast)
|
||||
|
||||
// Classified once, here, so that every screen below reads the same answer. Doing it
|
||||
// per screen would let two of them disagree about the window they are both in, which
|
||||
// is the failure mode of `BoxWithConstraints`-per-screen adaptivity.
|
||||
val breakpoint = currentBreakpoint()
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalExtendedColors provides extendedColorsFor(darkTheme),
|
||||
// Nothing scales it yet. It rides the theme now so that the breakpoint phase can
|
||||
// provide a wider instance without touching a call site -- which is the whole
|
||||
// reason for tokenising spacing rather than leaving it in literals.
|
||||
LocalSpacing provides Spacing(),
|
||||
LocalBreakpoint provides breakpoint,
|
||||
// The payoff for tokenising spacing in phase 2: the screen margin widens from
|
||||
// 16dp to 24dp at medium and above without a single call site changing.
|
||||
LocalSpacing provides spacingFor(breakpoint),
|
||||
) {
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The breakpoint thresholds against M3's published values, and the spacing they select.
|
||||
*
|
||||
* The thresholds are the kind of number that is wrong silently: a layout that switches to
|
||||
* two panes at 640dp instead of 600dp still looks like a working layout on every device
|
||||
* anybody happens to test, and only misbehaves in the 40dp nobody opens. Asserting the
|
||||
* boundary from both sides is the only way that shows up.
|
||||
*/
|
||||
class BreakpointTest {
|
||||
|
||||
/** m3.material.io/foundations/layout/applying-layout/window-size-classes, September 2026. */
|
||||
private val published = listOf(
|
||||
Breakpoint.Compact to 0,
|
||||
Breakpoint.Medium to 600,
|
||||
Breakpoint.Expanded to 840,
|
||||
Breakpoint.Large to 1200,
|
||||
Breakpoint.ExtraLarge to 1600,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `each breakpoint starts at its published width`() {
|
||||
assertEquals(published.size, Breakpoint.entries.size, "a breakpoint was added or removed")
|
||||
published.forEach { (breakpoint, lowerBound) ->
|
||||
assertEquals(
|
||||
lowerBound.dp,
|
||||
breakpoint.minWidth,
|
||||
"$breakpoint starts at ${breakpoint.minWidth}, M3 says $lowerBound.dp",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the lower bound belongs to the breakpoint it opens`() {
|
||||
// Half-open on the upper bound: exactly 600dp is Medium, and 599dp is Compact.
|
||||
// Off by one here and a phone in landscape gets the tablet layout, or does not.
|
||||
published.forEach { (breakpoint, lowerBound) ->
|
||||
assertEquals(
|
||||
breakpoint,
|
||||
Breakpoint.ofWidth(lowerBound.dp),
|
||||
"${lowerBound}dp should be exactly at the bottom of $breakpoint",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one dp below a lower bound is the breakpoint beneath it`() {
|
||||
published.drop(1).forEachIndexed { indexBefore, (breakpoint, lowerBound) ->
|
||||
val beneath = published[indexBefore].first
|
||||
assertEquals(
|
||||
beneath,
|
||||
Breakpoint.ofWidth((lowerBound - 1).dp),
|
||||
"${lowerBound - 1}dp fell in $breakpoint rather than $beneath",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the widths the phase is meant to be checked at land where the plan says`() {
|
||||
// The five window widths phase 6's acceptance criterion names. If one of these
|
||||
// ever moves, the manual check and the code have stopped talking about the same
|
||||
// thing.
|
||||
assertEquals(Breakpoint.Compact, Breakpoint.ofWidth(400.dp))
|
||||
assertEquals(Breakpoint.Medium, Breakpoint.ofWidth(700.dp))
|
||||
assertEquals(Breakpoint.Expanded, Breakpoint.ofWidth(1000.dp))
|
||||
assertEquals(Breakpoint.Large, Breakpoint.ofWidth(1400.dp))
|
||||
assertEquals(Breakpoint.ExtraLarge, Breakpoint.ofWidth(1800.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero or negative width is compact rather than an error`() {
|
||||
// A window has zero width for one frame on desktop, before the first layout pass,
|
||||
// and `ofWidth` is called from the theme every composition. Throwing there would
|
||||
// take the app down on a resize.
|
||||
assertEquals(Breakpoint.Compact, Breakpoint.ofWidth(0.dp))
|
||||
assertEquals(Breakpoint.Compact, Breakpoint.ofWidth((-1).dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isAtLeast reads up the scale and not down it`() {
|
||||
assertTrue(Breakpoint.Large.isAtLeast(Breakpoint.Expanded))
|
||||
assertTrue(Breakpoint.Expanded.isAtLeast(Breakpoint.Expanded))
|
||||
assertTrue(!Breakpoint.Medium.isAtLeast(Breakpoint.Expanded))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the screen margin widens at medium and holds there`() {
|
||||
// M3 publishes 16dp compact, 24dp for every wider breakpoint -- it does not keep
|
||||
// growing. A margin that scaled with the window would push a bounded column of
|
||||
// text further from the edge for no reason at 1800dp.
|
||||
assertEquals(16.dp, spacingFor(Breakpoint.Compact).screenMargin)
|
||||
listOf(Breakpoint.Medium, Breakpoint.Expanded, Breakpoint.Large, Breakpoint.ExtraLarge)
|
||||
.forEach { assertEquals(24.dp, spacingFor(it).screenMargin, "$it") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing but the screen margin moves with the breakpoint`() {
|
||||
// The scale itself is absolute -- space200 is 16dp in every window -- and the
|
||||
// other semantic names are component decisions. This is the assertion that stops
|
||||
// a later "make it breathe on desktop" edit from quietly turning the whole scale
|
||||
// into a zoom factor.
|
||||
val compact = spacingFor(Breakpoint.Compact)
|
||||
val wide = spacingFor(Breakpoint.ExtraLarge)
|
||||
|
||||
assertEquals(compact, wide.copy(screenMargin = compact.screenMargin))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every breakpoint gets one of two shared instances`() {
|
||||
// LocalSpacing is a static composition local: it invalidates on identity, not on
|
||||
// equality, so handing it a freshly built but equal Spacing every recomposition
|
||||
// would restart every composition that reads spacing.
|
||||
assertSame(spacingFor(Breakpoint.Medium), spacingFor(Breakpoint.ExtraLarge))
|
||||
assertSame(spacingFor(Breakpoint.Compact), spacingFor(Breakpoint.Compact))
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@ ksp = "2.3.6"
|
||||
ktor = "3.5.2"
|
||||
lightningKmpCore = "1.11.5"
|
||||
material3 = "1.10.0-alpha05"
|
||||
# The version material3 1.10.0-alpha05 itself resolves: its
|
||||
# material3-adaptive-navigation-suite pom asks for adaptive 1.2.0. Picking the newer
|
||||
# 1.3.0-beta02 would drag window-core 1.5.0 in beside the 1.4.0 the pinned material3
|
||||
# compiled against, for a large/extra-large breakpoint pair that 1.2.0 already computes
|
||||
# through `supportLargeAndXLargeWidth`.
|
||||
material3Adaptive = "1.2.0"
|
||||
materialIconsCore = "1.7.3"
|
||||
materialIconsExtended = "1.7.3"
|
||||
navigationCompose = "2.9.2"
|
||||
@@ -54,6 +60,7 @@ compose-components-resources = { module = "org.jetbrains.compose.components:comp
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||
compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "material3Adaptive" }
|
||||
compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "materialIconsCore" }
|
||||
compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
|
||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
||||
|
||||
Reference in New Issue
Block a user