mirror of
https://github.com/corda/corda.git
synced 2025-01-19 03:06:36 +00:00
client: Add ObservableMap utilities and tests
This commit is contained in:
parent
a26908e83b
commit
99e758e021
@ -15,6 +15,10 @@ import kotlin.comparisons.compareValues
|
||||
* adding/deleting aggregations as expected.
|
||||
*
|
||||
* The ordering of the exposed list is based on the [hashCode] of keys.
|
||||
* The ordering of the groups themselves is based on the [hashCode] of elements.
|
||||
*
|
||||
* Warning: If there are two elements [E] in the source list that have the same [hashCode] then it is not deterministic
|
||||
* which one will be removed if one is removed from the source list!
|
||||
*
|
||||
* Example:
|
||||
* val statesGroupedByCurrency = AggregatedList(states, { state -> state.currency }) { currency, group ->
|
||||
@ -33,7 +37,7 @@ import kotlin.comparisons.compareValues
|
||||
* @param toKey Function to extract the key from an element.
|
||||
* @param assemble Function to assemble the aggregation into the exposed [A].
|
||||
*/
|
||||
class AggregatedList<A, E, K : Any>(
|
||||
class AggregatedList<A, E : Any, K : Any>(
|
||||
list: ObservableList<out E>,
|
||||
val toKey: (E) -> K,
|
||||
val assemble: (K, ObservableList<E>) -> A
|
||||
@ -42,6 +46,7 @@ class AggregatedList<A, E, K : Any>(
|
||||
private class AggregationGroup<E, out A>(
|
||||
val keyHashCode: Int,
|
||||
val value: A,
|
||||
// Invariant: sorted by E.hashCode()
|
||||
val elements: ObservableList<E>
|
||||
)
|
||||
|
||||
@ -102,7 +107,15 @@ class AggregatedList<A, E, K : Any>(
|
||||
if (aggregationGroup.elements.size == 1) {
|
||||
return Pair(index, aggregationList.removeAt(index))
|
||||
}
|
||||
aggregationGroup.elements.remove(removedItem)
|
||||
val elementHashCode = removedItem.hashCode()
|
||||
val removeIndex = aggregationGroup.elements.binarySearch(
|
||||
comparison = { element -> compareValues(elementHashCode, element.hashCode()) }
|
||||
)
|
||||
if (removeIndex < 0) {
|
||||
throw IllegalStateException("Cannot find removed element $removedItem in group")
|
||||
} else {
|
||||
aggregationGroup.elements.removeAt(removeIndex)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@ -110,10 +123,10 @@ class AggregatedList<A, E, K : Any>(
|
||||
private fun addItem(addedItem: E): Int? {
|
||||
val key = toKey(addedItem)
|
||||
val keyHashCode = key.hashCode()
|
||||
val index = aggregationList.binarySearch(
|
||||
val aggregationIndex = aggregationList.binarySearch(
|
||||
comparison = { group -> compareValues(keyHashCode, group.keyHashCode.hashCode()) }
|
||||
)
|
||||
if (index < 0) {
|
||||
if (aggregationIndex < 0) {
|
||||
// New aggregation
|
||||
val observableGroupElements = FXCollections.observableArrayList<E>()
|
||||
observableGroupElements.add(addedItem)
|
||||
@ -122,11 +135,21 @@ class AggregatedList<A, E, K : Any>(
|
||||
value = assemble(key, observableGroupElements),
|
||||
elements = observableGroupElements
|
||||
)
|
||||
val insertIndex = -index - 1
|
||||
val insertIndex = -aggregationIndex - 1
|
||||
aggregationList.add(insertIndex, aggregationGroup)
|
||||
return insertIndex
|
||||
} else {
|
||||
aggregationList[index].elements.add(addedItem)
|
||||
val elements = aggregationList[aggregationIndex].elements
|
||||
val elementIndex = elements.binarySearch(
|
||||
comparison = { element -> compareValues(keyHashCode, element.hashCode()) }
|
||||
)
|
||||
val addIndex = if (elementIndex < 0) {
|
||||
-elementIndex - 1
|
||||
} else {
|
||||
// There is an existing element with the same hash (which is fine)
|
||||
elementIndex
|
||||
}
|
||||
elements.add(addIndex, addedItem)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,58 @@
|
||||
package com.r3corda.client.fxutils
|
||||
|
||||
import javafx.collections.ListChangeListener
|
||||
import javafx.collections.ObservableList
|
||||
import javafx.collections.ObservableMap
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* [AssociatedList] creates an [ObservableMap] from an [ObservableList] by associating each list element with a unique key.
|
||||
* It is *not* allowed to have several elements map to the same value!
|
||||
*
|
||||
* @param sourceList The source list.
|
||||
* @param toKey Function returning the key.
|
||||
* @param assemble The function to assemble the final map element from the list element and the associated key.
|
||||
*/
|
||||
class AssociatedList<K, out A, B>(
|
||||
val sourceList: ObservableList<out A>,
|
||||
toKey: (A) -> K,
|
||||
assemble: (K, A) -> B
|
||||
) : ReadOnlyBackedObservableMapBase<K, B, Unit>() {
|
||||
init {
|
||||
sourceList.forEach {
|
||||
val key = toKey(it)
|
||||
backingMap.set(key, Pair(assemble(key, it), Unit))
|
||||
}
|
||||
sourceList.addListener { change: ListChangeListener.Change<out A> ->
|
||||
while (change.next()) {
|
||||
if (change.wasPermutated()) {
|
||||
} else if (change.wasUpdated()) {
|
||||
} else {
|
||||
val removedSourceMap = change.removed.associateBy(toKey)
|
||||
val addedSourceMap = change.addedSubList.associateBy(toKey)
|
||||
val removedMap = HashMap<K, B>()
|
||||
val addedMap = HashMap<K, B>()
|
||||
removedSourceMap.forEach {
|
||||
val removed = backingMap.remove(it.key)?.first
|
||||
removed ?: throw IllegalStateException("Removed list does not associate")
|
||||
removedMap.put(it.key, removed)
|
||||
}
|
||||
addedSourceMap.forEach {
|
||||
val oldValue = backingMap.get(it.key)
|
||||
val newValue = if (oldValue == null) {
|
||||
assemble(it.key, it.value)
|
||||
} else {
|
||||
throw IllegalStateException("Several elements associated with same key")
|
||||
}
|
||||
backingMap.put(it.key, Pair(newValue, Unit))
|
||||
addedMap.put(it.key, newValue)
|
||||
}
|
||||
val keys = removedMap.keys + addedMap.keys
|
||||
keys.forEach { key ->
|
||||
fireChange(createMapChange(key, removedMap.get(key), addedMap.get(key)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
package com.r3corda.client.fxutils
|
||||
|
||||
import javafx.collections.FXCollections
|
||||
import javafx.collections.MapChangeListener
|
||||
import javafx.collections.ObservableList
|
||||
import javafx.collections.ObservableMap
|
||||
import kotlin.comparisons.compareValues
|
||||
|
||||
/**
|
||||
* [MapValuesList] takes an [ObservableMap] and returns its values as an [ObservableList].
|
||||
* The order of returned elements is deterministic but unspecified.
|
||||
*/
|
||||
class MapValuesList<K, A> private constructor(
|
||||
sourceMap: ObservableMap<K, A>,
|
||||
private val backingList: ObservableList<Map.Entry<K, A>>, // sorted by K.hashCode()
|
||||
private val exposedList: ObservableList<A>
|
||||
) : ObservableList<A> by exposedList {
|
||||
|
||||
companion object {
|
||||
fun <K, A> create(sourceMap: ObservableMap<K, A>): MapValuesList<K, A> {
|
||||
val backingList = FXCollections.observableArrayList<Map.Entry<K, A>>(sourceMap.entries.sortedBy { it.key!!.hashCode() })
|
||||
return MapValuesList(sourceMap, backingList, backingList.map { it.value })
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
sourceMap.addListener { change: MapChangeListener.Change<out K, out A> ->
|
||||
val keyHashCode = change.key!!.hashCode()
|
||||
if (change.wasRemoved()) {
|
||||
val removeIndex = backingList.binarySearch(
|
||||
comparison = { entry -> compareValues(keyHashCode, entry.key!!.hashCode()) }
|
||||
)
|
||||
if (removeIndex < 0) {
|
||||
throw IllegalStateException("Removed value does not map")
|
||||
}
|
||||
if (change.wasAdded()) {
|
||||
backingList[removeIndex] = object : Map.Entry<K, A> {
|
||||
override val key = change.key
|
||||
override val value = change.valueAdded
|
||||
}
|
||||
} else {
|
||||
backingList.removeAt(removeIndex)
|
||||
}
|
||||
} else if (change.wasAdded()) {
|
||||
val index = backingList.binarySearch(
|
||||
comparison = { entry -> compareValues(keyHashCode, entry.key!!.hashCode()) }
|
||||
)
|
||||
val addIndex = -index - 1
|
||||
backingList.add(addIndex, object : Map.Entry<K, A> {
|
||||
override val key = change.key
|
||||
override val value = change.valueAdded
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -36,3 +36,12 @@ fun <A, B, C> Observable<A>.foldToObservableList(
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* This variant simply exposes all events in the list, in order of arrival.
|
||||
*/
|
||||
fun <A> Observable<A>.foldToObservableList(): ObservableList<A> {
|
||||
return foldToObservableList(Unit) { newElement, _unit, list ->
|
||||
list.add(newElement)
|
||||
}
|
||||
}
|
||||
|
@ -1,22 +0,0 @@
|
||||
package com.r3corda.client.fxutils
|
||||
|
||||
import javafx.beans.property.SimpleObjectProperty
|
||||
import javafx.beans.value.ObservableValue
|
||||
import javafx.collections.MapChangeListener
|
||||
import javafx.collections.ObservableMap
|
||||
|
||||
|
||||
fun <K, V> ObservableMap<K, V>.getObservableValue(key: K): ObservableValue<V?> {
|
||||
val property = SimpleObjectProperty(get(key))
|
||||
addListener { change: MapChangeListener.Change<out K, out V> ->
|
||||
if (change.key == key) {
|
||||
// This is true both when a fresh element was inserted and when an existing was updated
|
||||
if (change.wasAdded()) {
|
||||
property.set(change.valueAdded)
|
||||
} else if (change.wasRemoved()) {
|
||||
property.set(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return property
|
||||
}
|
@ -2,9 +2,12 @@ package com.r3corda.client.fxutils
|
||||
|
||||
import javafx.beans.binding.Bindings
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper
|
||||
import javafx.beans.property.SimpleObjectProperty
|
||||
import javafx.beans.value.ObservableValue
|
||||
import javafx.collections.FXCollections
|
||||
import javafx.collections.MapChangeListener
|
||||
import javafx.collections.ObservableList
|
||||
import javafx.collections.ObservableMap
|
||||
import javafx.collections.transformation.FilteredList
|
||||
import org.fxmisc.easybind.EasyBind
|
||||
import java.util.function.Predicate
|
||||
@ -114,3 +117,57 @@ fun <A> ObservableList<out ObservableValue<out A>>.flatten(): ObservableList<A>
|
||||
* val heights: ObservableList<Long> = people.map(Person::height).sequence()
|
||||
*/
|
||||
fun <A> List<ObservableValue<out A>>.sequence(): ObservableList<A> = FlattenedList(FXCollections.observableArrayList(this))
|
||||
|
||||
/**
|
||||
* val people: ObservableList<Person> = (..)
|
||||
* val nameToHeight: ObservableMap<String, Long> = people.associateBy(Person::name) { name, person -> person.height }
|
||||
*/
|
||||
fun <K, A, B> ObservableList<out A>.associateBy(toKey: (A) -> K, assemble: (K, A) -> B): ObservableMap<K, B> {
|
||||
return AssociatedList(this, toKey, assemble)
|
||||
}
|
||||
|
||||
/**
|
||||
* val people: ObservableList<Person> = (..)
|
||||
* val nameToPerson: ObservableMap<String, Person> = people.associateBy(Person::name)
|
||||
*/
|
||||
fun <K, A> ObservableList<out A>.associateBy(toKey: (A) -> K): ObservableMap<K, A> {
|
||||
return associateBy(toKey) { key, value -> value }
|
||||
}
|
||||
|
||||
/**
|
||||
* val people: ObservableList<Person> = (..)
|
||||
* val heightToNames: ObservableMap<Long, ObservableList<String>> = people.associateByAggregation(Person::height) { name, person -> person.name }
|
||||
*/
|
||||
fun <K : Any, A : Any, B> ObservableList<out A>.associateByAggregation(toKey: (A) -> K, assemble: (K, A) -> B): ObservableMap<K, ObservableList<B>> {
|
||||
return AssociatedList(AggregatedList(this, toKey) { key, members -> Pair(key, members) }, { it.first }) { key, pair ->
|
||||
pair.second.map { assemble(key, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* val people: ObservableList<Person> = (..)
|
||||
* val heightToPeople: ObservableMap<Long, ObservableList<Person>> = people.associateByAggregation(Person::height)
|
||||
*/
|
||||
fun <K : Any, A : Any> ObservableList<out A>.associateByAggregation(toKey: (A) -> K): ObservableMap<K, ObservableList<A>> {
|
||||
return associateByAggregation(toKey) { key, value -> value }
|
||||
}
|
||||
|
||||
/**
|
||||
* val nameToPerson: ObservableMap<String, Person> = (..)
|
||||
* val john: ObservableValue<Person?> = nameToPerson.getObservableValue("John")
|
||||
*/
|
||||
fun <K, V> ObservableMap<K, V>.getObservableValue(key: K): ObservableValue<V?> {
|
||||
val property = SimpleObjectProperty(get(key))
|
||||
addListener { change: MapChangeListener.Change<out K, out V> ->
|
||||
if (change.key == key) {
|
||||
// This is true both when a fresh element was inserted and when an existing was updated
|
||||
if (change.wasAdded()) {
|
||||
property.set(change.valueAdded)
|
||||
} else if (change.wasRemoved()) {
|
||||
property.set(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return property
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,95 @@
|
||||
package com.r3corda.client.fxutils
|
||||
|
||||
import com.sun.javafx.collections.MapListenerHelper
|
||||
import javafx.beans.InvalidationListener
|
||||
import javafx.collections.MapChangeListener
|
||||
import javafx.collections.ObservableMap
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* [ReadOnlyBackedObservableMapBase] is a base class implementing all abstract functions required for an [ObservableMap]
|
||||
* using a backing HashMap that subclasses should modify.
|
||||
*
|
||||
* Non-read-only API calls throw.
|
||||
*
|
||||
* @param K The key type.
|
||||
* @param A The exposed map element type.
|
||||
* @param B Auxiliary data subclasses may wish to store in the backing map.
|
||||
*/
|
||||
open class ReadOnlyBackedObservableMapBase<K, A, B> : ObservableMap<K, A> {
|
||||
protected val backingMap = HashMap<K, Pair<A, B>>()
|
||||
private var mapListenerHelper: MapListenerHelper<K, A>? = null
|
||||
|
||||
protected fun fireChange(change: MapChangeListener.Change<out K, out A>) {
|
||||
MapListenerHelper.fireValueChangedEvent(mapListenerHelper, change)
|
||||
}
|
||||
|
||||
override fun addListener(listener: InvalidationListener) {
|
||||
mapListenerHelper = MapListenerHelper.addListener(mapListenerHelper, listener)
|
||||
}
|
||||
|
||||
override fun addListener(listener: MapChangeListener<in K, in A>?) {
|
||||
mapListenerHelper = MapListenerHelper.addListener(mapListenerHelper, listener)
|
||||
}
|
||||
|
||||
override fun removeListener(listener: InvalidationListener?) {
|
||||
mapListenerHelper = MapListenerHelper.removeListener(mapListenerHelper, listener)
|
||||
}
|
||||
|
||||
override fun removeListener(listener: MapChangeListener<in K, in A>?) {
|
||||
mapListenerHelper = MapListenerHelper.removeListener(mapListenerHelper, listener)
|
||||
}
|
||||
|
||||
override val size: Int get() = backingMap.size
|
||||
|
||||
override fun containsKey(key: K) = backingMap.containsKey(key)
|
||||
|
||||
override fun containsValue(value: A) = backingMap.any { it.value.first == value }
|
||||
|
||||
override fun get(key: K) = backingMap.get(key)?.first
|
||||
|
||||
override fun isEmpty() = backingMap.isEmpty()
|
||||
|
||||
override val entries: MutableSet<MutableMap.MutableEntry<K, A>> get() = backingMap.entries.fold(mutableSetOf()) { set, entry ->
|
||||
set.add(object : MutableMap.MutableEntry<K, A> {
|
||||
override var value: A = entry.value.first
|
||||
override val key = entry.key
|
||||
override fun setValue(newValue: A): A {
|
||||
val old = value
|
||||
value = newValue
|
||||
return old
|
||||
}
|
||||
})
|
||||
set
|
||||
}
|
||||
override val keys: MutableSet<K> get() = backingMap.keys
|
||||
override val values: MutableCollection<A> get() = ArrayList(backingMap.values.map { it.first })
|
||||
|
||||
override fun clear() {
|
||||
throw UnsupportedOperationException("clear() can't be called on ReadOnlyObservableMapBase")
|
||||
}
|
||||
|
||||
override fun put(key: K, value: A): A {
|
||||
throw UnsupportedOperationException("put() can't be called on ReadOnlyObservableMapBase")
|
||||
}
|
||||
|
||||
override fun putAll(from: Map<out K, A>) {
|
||||
throw UnsupportedOperationException("putAll() can't be called on ReadOnlyObservableMapBase")
|
||||
}
|
||||
|
||||
override fun remove(key: K): A {
|
||||
throw UnsupportedOperationException("remove() can't be called on ReadOnlyObservableMapBase")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun <A, K> ObservableMap<K, A>.createMapChange(key: K, removedValue: A?, addedValue: A?): MapChangeListener.Change<K, A> {
|
||||
return object : MapChangeListener.Change<K, A>(this) {
|
||||
override fun getKey() = key
|
||||
override fun wasRemoved() = removedValue != null
|
||||
override fun wasAdded() = addedValue != null
|
||||
override fun getValueRemoved() = removedValue
|
||||
override fun getValueAdded() = addedValue
|
||||
}
|
||||
}
|
||||
|
@ -79,6 +79,22 @@ class AggregatedListTest {
|
||||
sourceList.removeAll(0, 1)
|
||||
require(replayedList.size == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleElementsWithSameHashWorks() {
|
||||
sourceList.addAll(0, 0)
|
||||
require(replayedList.size == 1)
|
||||
replayedList.forEach {
|
||||
when (it.first) {
|
||||
0 -> {
|
||||
require(it.second.size == 2)
|
||||
require(it.second[0] == 0)
|
||||
require(it.second[1] == 0)
|
||||
}
|
||||
else -> fail("No aggregation expected with key ${it.first}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -0,0 +1,73 @@
|
||||
package com.r3corda.client.fxutils
|
||||
|
||||
import javafx.collections.FXCollections
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class AssociatedListTest {
|
||||
|
||||
var sourceList = FXCollections.observableArrayList(0)
|
||||
var associatedList = AssociatedList(sourceList, { it % 3 }) { mod3, number -> number }
|
||||
var replayedMap = ReplayedMap(associatedList)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
sourceList = FXCollections.observableArrayList(0)
|
||||
associatedList = AssociatedList(sourceList, { it % 3 }) { mod3, number -> number }
|
||||
replayedMap = ReplayedMap(associatedList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addWorks() {
|
||||
require(replayedMap.size == 1)
|
||||
require(replayedMap[0] == 0)
|
||||
|
||||
sourceList.add(2)
|
||||
require(replayedMap.size == 2)
|
||||
require(replayedMap[0] == 0)
|
||||
require(replayedMap[2] == 2)
|
||||
|
||||
sourceList.add(0, 4)
|
||||
require(replayedMap.size == 3)
|
||||
require(replayedMap[0] == 0)
|
||||
require(replayedMap[2] == 2)
|
||||
require(replayedMap[1] == 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeWorks() {
|
||||
sourceList.addAll(2, 4)
|
||||
require(replayedMap.size == 3)
|
||||
|
||||
sourceList.removeAt(0)
|
||||
require(replayedMap.size == 2)
|
||||
require(replayedMap[2] == 2)
|
||||
require(replayedMap[1] == 4)
|
||||
|
||||
sourceList.add(1, 12)
|
||||
require(replayedMap.size == 3)
|
||||
require(replayedMap[2] == 2)
|
||||
require(replayedMap[1] == 4)
|
||||
require(replayedMap[0] == 12)
|
||||
|
||||
sourceList.clear()
|
||||
require(replayedMap.size == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateWorks() {
|
||||
sourceList.addAll(2, 4)
|
||||
require(replayedMap.size == 3)
|
||||
|
||||
sourceList[1] = 5
|
||||
require(replayedMap.size == 3)
|
||||
require(replayedMap[0] == 0)
|
||||
require(replayedMap[2] == 5)
|
||||
require(replayedMap[1] == 4)
|
||||
|
||||
sourceList.removeAt(1)
|
||||
require(replayedMap.size == 2)
|
||||
require(replayedMap[0] == 0)
|
||||
require(replayedMap[1] == 4)
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package com.r3corda.client.fxutils
|
||||
|
||||
import org.junit.Before
|
||||
|
||||
class MapValuesListTest {
|
||||
|
||||
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package com.r3corda.client.fxutils
|
||||
|
||||
import javafx.collections.MapChangeListener
|
||||
import javafx.collections.ObservableMap
|
||||
|
||||
class ReplayedMap<K, A>(sourceMap: ObservableMap<K, A>) : ReadOnlyBackedObservableMapBase<K, A, Unit>() {
|
||||
init {
|
||||
sourceMap.forEach {
|
||||
backingMap.set(it.key, Pair(it.value, Unit))
|
||||
}
|
||||
sourceMap.addListener { change: MapChangeListener.Change<out K, out A> ->
|
||||
if (change.wasRemoved()) {
|
||||
require(backingMap.remove(change.key)!!.first == change.valueRemoved)
|
||||
}
|
||||
if (change.wasAdded()) {
|
||||
backingMap.set(change.key, Pair(change.valueAdded, Unit))
|
||||
}
|
||||
fireChange(change)
|
||||
}
|
||||
}
|
||||
}
|
@ -154,7 +154,7 @@ class TransactionViewer: View() {
|
||||
is PartiallyResolvedTransaction.InputResolution.Unresolved -> null
|
||||
is PartiallyResolvedTransaction.InputResolution.Resolved -> resolution.stateAndRef
|
||||
}
|
||||
}.fold(listOf()) { inputs: List<StateAndRef<ContractState>>?, state: StateAndRef<ContractState>? ->
|
||||
}.foldObservable(listOf()) { inputs: List<StateAndRef<ContractState>>?, state: StateAndRef<ContractState>? ->
|
||||
if (inputs != null && state != null) {
|
||||
inputs + state
|
||||
} else {
|
||||
|
Loading…
Reference in New Issue
Block a user