Improves layout of the Relay Monitor events.
This commit is contained in:
@@ -182,6 +182,8 @@ import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
|||||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
|
||||||
|
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent
|
||||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||||
@@ -341,10 +343,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
}
|
}
|
||||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||||
|
|
||||||
fun observeEvents(filter: Filter): Flow<List<Event>> =
|
fun <T : Event> observeEvents(filter: Filter): Flow<List<T>> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
val cachedFilter =
|
val cachedFilter =
|
||||||
EventListMatchingFilter(filter, this@LocalCache::filter) {
|
EventListMatchingFilter<T>(filter, this@LocalCache::filter) {
|
||||||
trySend(it)
|
trySend(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +360,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents(filter).map { it.firstNotNullOfOrNull { it as? T } }
|
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents<T>(filter).map { it.firstOrNull() }
|
||||||
|
|
||||||
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
|
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
|
||||||
|
|
||||||
@@ -1349,6 +1351,18 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
wasVerified: Boolean,
|
wasVerified: Boolean,
|
||||||
) = consumeRegularEvent(event, relay, wasVerified)
|
) = consumeRegularEvent(event, relay, wasVerified)
|
||||||
|
|
||||||
|
private fun consume(
|
||||||
|
event: RelayDiscoveryEvent,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
wasVerified: Boolean,
|
||||||
|
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
|
|
||||||
|
private fun consume(
|
||||||
|
event: RelayMonitorEvent,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
wasVerified: Boolean,
|
||||||
|
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
|
|
||||||
fun consume(
|
fun consume(
|
||||||
event: StatusEvent,
|
event: StatusEvent,
|
||||||
relay: NormalizedRelayUrl?,
|
relay: NormalizedRelayUrl?,
|
||||||
@@ -3208,6 +3222,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
is ZapPollEvent -> consume(event, relay, wasVerified)
|
is ZapPollEvent -> consume(event, relay, wasVerified)
|
||||||
is PollEvent -> consume(event, relay, wasVerified)
|
is PollEvent -> consume(event, relay, wasVerified)
|
||||||
is PollResponseEvent -> consume(event, relay, wasVerified)
|
is PollResponseEvent -> consume(event, relay, wasVerified)
|
||||||
|
is RelayDiscoveryEvent -> consume(event, relay, wasVerified)
|
||||||
|
is RelayMonitorEvent -> consume(event, relay, wasVerified)
|
||||||
is ReactionEvent -> consume(event, relay, wasVerified)
|
is ReactionEvent -> consume(event, relay, wasVerified)
|
||||||
is ContactCardEvent -> consume(event, relay, wasVerified)
|
is ContactCardEvent -> consume(event, relay, wasVerified)
|
||||||
is RelaySetEvent -> consume(event, relay, wasVerified)
|
is RelaySetEvent -> consume(event, relay, wasVerified)
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ class UserProfileFollowersUserFeedViewModel(
|
|||||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||||
val followersFlow: StateFlow<List<User>> =
|
val followersFlow: StateFlow<List<User>> =
|
||||||
account.cache
|
account.cache
|
||||||
.observeEvents(followerFilter)
|
.observeEvents<Event>(followerFilter)
|
||||||
.sample(500)
|
.sample(500)
|
||||||
.map { followerContactLists ->
|
.map { followerContactLists ->
|
||||||
followerContactLists.toNonHiddenOwners().sortedWith(sortingModel)
|
followerContactLists.toNonHiddenOwners().sortedWith(sortingModel)
|
||||||
|
|||||||
+6
-9
@@ -28,7 +28,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.utils.BigDecimal
|
import com.vitorpamplona.quartz.utils.BigDecimal
|
||||||
@@ -96,16 +95,14 @@ class UserProfileZapsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun List<Event>.sumAmountsByUser(): List<ZapAmount> {
|
suspend fun List<LnZapEvent>.sumAmountsByUser(): List<ZapAmount> {
|
||||||
val results = mutableMapOf<User, BigDecimal>()
|
val results = mutableMapOf<User, BigDecimal>()
|
||||||
|
|
||||||
this.forEach { zapEvent ->
|
this.forEach { zapEvent ->
|
||||||
if (zapEvent is LnZapEvent) {
|
val zapAmount = mapRequest(zapEvent)
|
||||||
val zapAmount = mapRequest(zapEvent)
|
if (zapAmount != null) {
|
||||||
if (zapAmount != null) {
|
val existingAmount = results[zapAmount.user] ?: BigDecimal.ZERO
|
||||||
val existingAmount = results[zapAmount.user] ?: BigDecimal.ZERO
|
results[zapAmount.user] = existingAmount + zapAmount.amount
|
||||||
results[zapAmount.user] = existingAmount + zapAmount.amount
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +112,7 @@ class UserProfileZapsViewModel(
|
|||||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||||
val receivedZapAmountsByUser: StateFlow<List<ZapAmount>> =
|
val receivedZapAmountsByUser: StateFlow<List<ZapAmount>> =
|
||||||
account.cache
|
account.cache
|
||||||
.observeEvents(zapsToUser)
|
.observeEvents<LnZapEvent>(zapsToUser)
|
||||||
.sample(500)
|
.sample(500)
|
||||||
.map { zapEvents ->
|
.map { zapEvents ->
|
||||||
zapEvents.sumAmountsByUser()
|
zapEvents.sumAmountsByUser()
|
||||||
|
|||||||
+12
-48
@@ -85,7 +85,6 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -102,6 +101,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish
|
import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish
|
||||||
@@ -115,6 +115,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
|||||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.note.graspLink
|
import com.vitorpamplona.amethyst.ui.note.graspLink
|
||||||
import com.vitorpamplona.amethyst.ui.note.nipLink
|
import com.vitorpamplona.amethyst.ui.note.nipLink
|
||||||
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||||
@@ -283,6 +284,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlin.contracts.ExperimentalContracts
|
import kotlin.contracts.ExperimentalContracts
|
||||||
import kotlin.contracts.contract
|
import kotlin.contracts.contract
|
||||||
|
|
||||||
@@ -440,7 +442,7 @@ fun RelayInformationBody(
|
|||||||
|
|
||||||
if (discoveryEvents.isNotEmpty()) {
|
if (discoveryEvents.isNotEmpty()) {
|
||||||
item { SectionHeader(stringRes(R.string.relay_monitor_reports)) }
|
item { SectionHeader(stringRes(R.string.relay_monitor_reports)) }
|
||||||
items(discoveryEvents, key = { it.id }) { event ->
|
items(discoveryEvents, key = { it.addressTag() }) { event ->
|
||||||
RelayMonitorReportCard(event, accountViewModel, nav)
|
RelayMonitorReportCard(event, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1575,21 +1577,19 @@ fun PoliciesCard(relay: Nip11RelayInformation) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun loadRelayDiscoveryEvents(relay: NormalizedRelayUrl): State<ImmutableList<RelayDiscoveryEvent>> =
|
fun loadRelayDiscoveryEvents(relay: NormalizedRelayUrl): State<ImmutableList<RelayDiscoveryEvent>> =
|
||||||
produceState<ImmutableList<RelayDiscoveryEvent>>(persistentListOf(), relay) {
|
remember(relay) {
|
||||||
LocalCache
|
LocalCache
|
||||||
.observeEvents(
|
.observeEvents<RelayDiscoveryEvent>(
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(RelayDiscoveryEvent.KIND),
|
kinds = listOf(RelayDiscoveryEvent.KIND),
|
||||||
tags = mapOf("d" to listOf(relay.url)),
|
tags = mapOf("d" to listOf(relay.url)),
|
||||||
|
since = TimeUtils.oneWeekAgo(),
|
||||||
|
limit = 3,
|
||||||
),
|
),
|
||||||
).collect { events ->
|
).map {
|
||||||
value =
|
it.toImmutableList()
|
||||||
events
|
|
||||||
.filterIsInstance<RelayDiscoveryEvent>()
|
|
||||||
.sortedByDescending { it.createdAt }
|
|
||||||
.toImmutableList()
|
|
||||||
}
|
}
|
||||||
}
|
}.collectAsStateWithLifecycle(persistentListOf())
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RelayMonitorReportCard(
|
private fun RelayMonitorReportCard(
|
||||||
@@ -1622,11 +1622,7 @@ private fun RelayMonitorReportCard(
|
|||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
UserCompose(
|
UsernameDisplay(user, weight = Modifier.weight(1f), accountViewModel = accountViewModel)
|
||||||
baseUser = user,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
nav = nav,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1646,11 +1642,6 @@ private fun RelayMonitorReportCard(
|
|||||||
val rttWrite = event.rttWrite()
|
val rttWrite = event.rttWrite()
|
||||||
|
|
||||||
if (rttOpen != null || rttRead != null || rttWrite != null) {
|
if (rttOpen != null || rttRead != null || rttWrite != null) {
|
||||||
Text(
|
|
||||||
stringRes(R.string.relay_monitor_rtt),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
@@ -1687,33 +1678,6 @@ private fun RelayMonitorReportCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supported NIPs
|
|
||||||
val supportedNips = event.supportedNips()
|
|
||||||
if (supportedNips.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
stringRes(R.string.relay_monitor_supported_nips),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
val uri = LocalUriHandler.current
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
supportedNips.forEach { nip ->
|
|
||||||
val nipStr = nip.toString().padStart(2, '0')
|
|
||||||
SuggestionChip(
|
|
||||||
onClick = {
|
|
||||||
runCatching {
|
|
||||||
uri.openUri(nipLink(nipStr))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(nipStr) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Requirements
|
// Requirements
|
||||||
val requirements = event.requirements()
|
val requirements = event.requirements()
|
||||||
if (requirements.isNotEmpty()) {
|
if (requirements.isNotEmpty()) {
|
||||||
|
|||||||
+18
-5
@@ -20,7 +20,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.model.observables
|
package com.vitorpamplona.amethyst.commons.model.observables
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import java.util.SortedSet
|
import java.util.SortedSet
|
||||||
@@ -31,18 +33,27 @@ import java.util.concurrent.ConcurrentSkipListSet
|
|||||||
* that is updated every time a new event that matches
|
* that is updated every time a new event that matches
|
||||||
* the filter is received, including addressables.
|
* the filter is received, including addressables.
|
||||||
*/
|
*/
|
||||||
class EventListMatchingFilter(
|
class EventListMatchingFilter<T : Event>(
|
||||||
private val filter: Filter,
|
private val filter: Filter,
|
||||||
private val atOnce: (filter: Filter) -> SortedSet<Note>,
|
private val atOnce: (filter: Filter) -> SortedSet<Note>,
|
||||||
private val update: (List<Event>) -> Unit,
|
private val update: (List<T>) -> Unit,
|
||||||
) : Observable {
|
) : Observable {
|
||||||
// Keeping this here blocks it from being cleared from memory
|
// Keeping this here blocks it from being cleared from memory
|
||||||
var currentResults: ConcurrentSkipListSet<Note> = ConcurrentSkipListSet(CreatedAtIdHexComparator)
|
var currentResults: ConcurrentSkipListSet<Note> = ConcurrentSkipListSet(CreatedAtIdHexComparator)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun new(
|
override fun new(
|
||||||
event: Event,
|
event: Event,
|
||||||
note: Note,
|
note: Note,
|
||||||
) {
|
) {
|
||||||
|
if (event is AddressableEvent && note !is AddressableNote) {
|
||||||
|
// event update
|
||||||
|
if (currentResults.contains(note)) {
|
||||||
|
update(currentResults.mapNotNull { it.event as? T })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.match(event)) {
|
if (filter.match(event)) {
|
||||||
currentResults.add(note)
|
currentResults.add(note)
|
||||||
val limit = filter.limit
|
val limit = filter.limit
|
||||||
@@ -50,18 +61,20 @@ class EventListMatchingFilter(
|
|||||||
currentResults.remove(currentResults.last())
|
currentResults.remove(currentResults.last())
|
||||||
}
|
}
|
||||||
|
|
||||||
update(currentResults.mapNotNull { it.event })
|
update(currentResults.mapNotNull { it.event as? T })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun remove(note: Note) {
|
override fun remove(note: Note) {
|
||||||
if (currentResults.remove(note)) {
|
if (currentResults.remove(note)) {
|
||||||
update(currentResults.mapNotNull { it.event })
|
update(currentResults.mapNotNull { it.event as? T })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun init() {
|
fun init() {
|
||||||
currentResults = ConcurrentSkipListSet(atOnce(filter))
|
currentResults = ConcurrentSkipListSet(atOnce(filter))
|
||||||
update(currentResults.mapNotNull { it.event })
|
update(currentResults.mapNotNull { it.event as? T })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -20,7 +20,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.model.observables
|
package com.vitorpamplona.amethyst.commons.model.observables
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||||
import com.vitorpamplona.amethyst.commons.model.Note
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import java.util.SortedSet
|
import java.util.SortedSet
|
||||||
@@ -43,6 +45,8 @@ class NoteListMatchingFilter(
|
|||||||
event: Event,
|
event: Event,
|
||||||
note: Note,
|
note: Note,
|
||||||
) {
|
) {
|
||||||
|
if (event is AddressableEvent && note !is AddressableNote) return
|
||||||
|
|
||||||
if (filter.match(event)) {
|
if (filter.match(event)) {
|
||||||
if (currentResults.add(note)) {
|
if (currentResults.add(note)) {
|
||||||
val limit = filter.limit
|
val limit = filter.limit
|
||||||
|
|||||||
Reference in New Issue
Block a user