Refactor scroll timer
This commit is contained in:
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.collection.arraySetOf
|
||||
import androidx.core.content.edit
|
||||
@@ -265,6 +266,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isWebtoonZoomEnable: Boolean
|
||||
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
|
||||
|
||||
@get:FloatRange(from = 0.0, to = 1.0)
|
||||
var readerAutoscrollSpeed: Float
|
||||
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
|
||||
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) }
|
||||
|
||||
fun isPagesPreloadEnabled(): Boolean {
|
||||
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
|
||||
return policy.isNetworkAllowed(connectivityManager)
|
||||
@@ -397,6 +403,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||
const val KEY_SSL_BYPASS = "ssl_bypass"
|
||||
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
|
||||
@@ -53,6 +53,7 @@ import org.koitharu.kotatsu.utils.ext.observeWithPrevious
|
||||
import org.koitharu.kotatsu.utils.ext.postDelayed
|
||||
import org.koitharu.kotatsu.utils.ext.setValueRounded
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReaderActivity :
|
||||
@@ -69,15 +70,18 @@ class ReaderActivity :
|
||||
|
||||
private val viewModel: ReaderViewModel by viewModels()
|
||||
|
||||
override var autoScrollSpeed: Float
|
||||
get() = scrollTimer.speed
|
||||
set(value) {
|
||||
scrollTimer.speed = value
|
||||
}
|
||||
|
||||
override val readerMode: ReaderMode?
|
||||
get() = readerManager.currentMode
|
||||
|
||||
override var isAutoScrollEnabled: Boolean
|
||||
get() = scrollTimer.isEnabled
|
||||
set(value) {
|
||||
scrollTimer.isEnabled = value
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var scrollTimerFactory: ScrollTimer.Factory
|
||||
|
||||
private lateinit var scrollTimer: ScrollTimer
|
||||
private lateinit var touchHelper: GridTouchHelper
|
||||
private lateinit var controlDelegate: ReaderControlDelegate
|
||||
@@ -91,7 +95,7 @@ class ReaderActivity :
|
||||
readerManager = ReaderManager(supportFragmentManager, R.id.container)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
touchHelper = GridTouchHelper(this, this)
|
||||
scrollTimer = ScrollTimer(this, this)
|
||||
scrollTimer = scrollTimerFactory.create(this, this)
|
||||
controlDelegate = ReaderControlDelegate(settings, this, this)
|
||||
binding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
|
||||
binding.slider.setLabelFormatter(PageLabelFormatter())
|
||||
@@ -133,6 +137,7 @@ class ReaderActivity :
|
||||
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
scrollTimer.onUserInteraction()
|
||||
idlingDetector.onUserInteraction()
|
||||
}
|
||||
|
||||
@@ -343,6 +348,11 @@ class ReaderActivity :
|
||||
setUiIsVisible(!binding.appbarTop.isVisible)
|
||||
}
|
||||
|
||||
override fun isReaderResumed(): Boolean {
|
||||
val reader = readerManager.currentReader ?: return false
|
||||
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
|
||||
}
|
||||
|
||||
private fun onReaderBarChanged(isBarEnabled: Boolean) {
|
||||
binding.infoBar.isVisible = isBarEnabled && binding.appbarTop.isGone
|
||||
}
|
||||
|
||||
@@ -142,5 +142,7 @@ class ReaderControlDelegate(
|
||||
fun scrollBy(delta: Int): Boolean
|
||||
|
||||
fun toggleUiVisibility()
|
||||
|
||||
fun isReaderResumed(): Boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,65 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
private const val MIN_SPEED = 0.1
|
||||
private const val MAX_DELAY = 80L
|
||||
private const val MAX_SWITCH_DELAY = 20_000L
|
||||
private const val MAX_DELAY = 60L
|
||||
private const val MAX_SWITCH_DELAY = 12_000L
|
||||
private const val INTERACTION_SKIP_MS = 1_000L
|
||||
|
||||
class ScrollTimer(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
private val listener: ReaderControlDelegate.OnInteractionListener,
|
||||
class ScrollTimer @AssistedInject constructor(
|
||||
@Assisted private val listener: ReaderControlDelegate.OnInteractionListener,
|
||||
@Assisted lifecycleOwner: LifecycleOwner,
|
||||
settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val coroutineScope = lifecycleOwner.lifecycleScope
|
||||
private var job: Job? = null
|
||||
private var delayMs: Long = 10L
|
||||
private var pageSwitchDelay: Long = 100L
|
||||
private var skip = 0L
|
||||
|
||||
@FloatRange(from = 0.0, to = 1.0)
|
||||
var speed: Float = 0f
|
||||
var isEnabled: Boolean = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
onSpeedChanged()
|
||||
restartJob()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSpeedChanged() {
|
||||
if (speed < MIN_SPEED) {
|
||||
init {
|
||||
settings.observeAsFlow(AppSettings.KEY_READER_AUTOSCROLL_SPEED) {
|
||||
readerAutoscrollSpeed
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.onEach {
|
||||
onSpeedChanged(it)
|
||||
}.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
fun onUserInteraction() {
|
||||
skip = INTERACTION_SKIP_MS
|
||||
}
|
||||
|
||||
private fun onSpeedChanged(speed: Float) {
|
||||
if (speed <= 0f) {
|
||||
delayMs = 0L
|
||||
pageSwitchDelay = 0L
|
||||
} else {
|
||||
val speedFactor = 1 - speed + MIN_SPEED
|
||||
val speedFactor = 1 - speed
|
||||
delayMs = (MAX_DELAY * speedFactor).roundToLong()
|
||||
pageSwitchDelay = (MAX_SWITCH_DELAY * speedFactor).roundToLong()
|
||||
}
|
||||
@@ -49,24 +70,39 @@ class ScrollTimer(
|
||||
|
||||
private fun restartJob() {
|
||||
job?.cancel()
|
||||
if (delayMs == 0L) {
|
||||
skip = 0
|
||||
if (!isEnabled || delayMs == 0L) {
|
||||
job = null
|
||||
return
|
||||
}
|
||||
job = lifecycleOwner.lifecycle.coroutineScope.launch {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
var accumulator = 0L
|
||||
while (isActive) {
|
||||
delay(delayMs)
|
||||
if (!listener.scrollBy(1)) {
|
||||
accumulator += delayMs
|
||||
}
|
||||
if (accumulator >= pageSwitchDelay) {
|
||||
listener.switchPageBy(1)
|
||||
accumulator -= pageSwitchDelay
|
||||
}
|
||||
job = coroutineScope.launch {
|
||||
var accumulator = 0L
|
||||
while (isActive) {
|
||||
delay(delayMs)
|
||||
if (!listener.isReaderResumed()) {
|
||||
continue
|
||||
}
|
||||
skip -= delayMs
|
||||
if (skip > 0) {
|
||||
continue
|
||||
}
|
||||
if (!listener.scrollBy(1)) {
|
||||
accumulator += delayMs
|
||||
}
|
||||
if (accumulator >= pageSwitchDelay) {
|
||||
listener.switchPageBy(1)
|
||||
accumulator -= pageSwitchDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: ReaderControlDelegate.OnInteractionListener,
|
||||
): ScrollTimer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package org.koitharu.kotatsu.reader.ui.config
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.google.android.material.slider.LabelFormatter
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderControlDelegate
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
@Deprecated("")
|
||||
class PageSwitchTimer(
|
||||
private val listener: ReaderControlDelegate.OnInteractionListener,
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
) {
|
||||
|
||||
var delaySec: Float = 0f
|
||||
set(value) {
|
||||
field = value
|
||||
delayMs = mapDelay(value)
|
||||
restartJob()
|
||||
}
|
||||
private var delayMs = 0L
|
||||
|
||||
fun onUserInteraction() {
|
||||
restartJob()
|
||||
}
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
private fun restartJob() {
|
||||
job?.cancel()
|
||||
if (delayMs == 0L) {
|
||||
job = null
|
||||
return
|
||||
}
|
||||
job = lifecycleOwner.lifecycle.coroutineScope.launch {
|
||||
// FIXME: pause when bs is opened
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
while (isActive) {
|
||||
delay(delayMs)
|
||||
listener.switchPageBy(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DelayLabelFormatter(resources: Resources) : LabelFormatter {
|
||||
|
||||
private val textOff = resources.getString(R.string.off_short)
|
||||
private val textSec = resources.getString(R.string.seconds_pattern)
|
||||
|
||||
override fun getFormattedValue(value: Float): String {
|
||||
val ms = mapDelay(value)
|
||||
return if (ms == 0L) textOff else textSec.format((ms / 1000.0).format(1))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DELAY_MIN = 2000L
|
||||
|
||||
fun mapDelay(value: Float): Long {
|
||||
val delay = (value * 1000L).roundToLong()
|
||||
return if (delay < DELAY_MIN) 0L else delay
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,25 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CompoundButton
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||
import com.google.android.material.slider.Slider
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
|
||||
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
|
||||
import org.koitharu.kotatsu.reader.ui.PageSaveContract
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
@@ -26,19 +32,24 @@ import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
|
||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReaderConfigBottomSheet :
|
||||
BaseBottomSheet<SheetReaderConfigBinding>(),
|
||||
ActivityResultCallback<Uri?>,
|
||||
View.OnClickListener,
|
||||
MaterialButtonToggleGroup.OnButtonCheckedListener,
|
||||
Slider.OnChangeListener {
|
||||
Slider.OnChangeListener, CompoundButton.OnCheckedChangeListener {
|
||||
|
||||
private val viewModel by activityViewModels<ReaderViewModel>()
|
||||
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
|
||||
private var orientationHelper: ScreenOrientationHelper? = null
|
||||
private lateinit var mode: ReaderMode
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
mode = arguments?.getInt(ARG_MODE)
|
||||
@@ -63,9 +74,17 @@ class ReaderConfigBottomSheet :
|
||||
binding.buttonSettings.setOnClickListener(this)
|
||||
binding.buttonColorFilter.setOnClickListener(this)
|
||||
binding.sliderTimer.addOnChangeListener(this)
|
||||
binding.switchScrollTimer.setOnCheckedChangeListener(this)
|
||||
|
||||
settings.observeAsLiveData(
|
||||
context = lifecycleScope.coroutineContext + Dispatchers.Default,
|
||||
key = AppSettings.KEY_READER_AUTOSCROLL_SPEED,
|
||||
valueProducer = { readerAutoscrollSpeed },
|
||||
).observe(viewLifecycleOwner) {
|
||||
binding.sliderTimer.value = it
|
||||
}
|
||||
findCallback()?.run {
|
||||
binding.sliderTimer.value = autoScrollSpeed
|
||||
binding.switchScrollTimer.isChecked = isAutoScrollEnabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +112,16 @@ class ReaderConfigBottomSheet :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
when (buttonView.id) {
|
||||
R.id.switch_scroll_timer -> {
|
||||
findCallback()?.isAutoScrollEnabled = isChecked
|
||||
binding.labelTimer.isVisible = isChecked
|
||||
binding.sliderTimer.isVisible = isChecked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
|
||||
if (!isChecked) {
|
||||
return
|
||||
@@ -111,7 +140,9 @@ class ReaderConfigBottomSheet :
|
||||
}
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
findCallback()?.autoScrollSpeed = value
|
||||
if (fromUser) {
|
||||
settings.readerAutoscrollSpeed = value
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(uri: Uri?) {
|
||||
@@ -135,8 +166,7 @@ class ReaderConfigBottomSheet :
|
||||
|
||||
interface Callback {
|
||||
|
||||
@get:FloatRange(from = 0.0, to = 1.0)
|
||||
var autoScrollSpeed: Float
|
||||
var isAutoScrollEnabled: Boolean
|
||||
|
||||
fun onReaderModeChanged(mode: ReaderMode)
|
||||
}
|
||||
|
||||
@@ -103,51 +103,44 @@
|
||||
android:text="@string/reader_mode_hint"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switch_scroll_timer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:listPreferredItemHeightSmall"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:ellipsize="end"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:singleLine="true"
|
||||
android:text="@string/automatic_scroll"
|
||||
android:textAppearance="?attr/textAppearanceButton"
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
app:drawableStartCompat="@drawable/ic_timer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/label_timer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_normal"
|
||||
android:baselineAligned="false"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd">
|
||||
android:layout_marginHorizontal="@dimen/margin_normal"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:text="@string/speed"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_timer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:text="@string/automatic_scroll"
|
||||
android:textAppearance="?attr/textAppearanceButton"
|
||||
android:textColor="@color/list_item_text_color"
|
||||
app:drawableStartCompat="@drawable/ic_timer"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/slider_timer"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintWidth_default="wrap" />
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/slider_timer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_normal"
|
||||
android:contentDescription="@string/automatic_scroll"
|
||||
android:labelFor="@id/textView_timer"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="1"
|
||||
app:labelBehavior="floating"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/textView_timer"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintWidth_min="120dp" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/slider_timer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/margin_normal"
|
||||
android:contentDescription="@string/automatic_scroll"
|
||||
android:labelFor="@id/switch_scroll_timer"
|
||||
android:valueFrom="0.2"
|
||||
android:valueTo="1"
|
||||
android:visibility="gone"
|
||||
app:labelBehavior="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.koitharu.kotatsu.base.ui.widgets.ListItemTextView
|
||||
android:id="@+id/button_color_filter"
|
||||
|
||||
@@ -430,4 +430,5 @@
|
||||
<string name="settings_apply_restart_required">Please restart the application to apply these changes</string>
|
||||
<string name="comics_archive_import_description">You can select one or more .cbz or .zip files, each file will be recognized as a separate manga.</string>
|
||||
<string name="folder_with_images_import_description">You can select a directory with archives or images. Each archive (or subdirectory) will be recognized as a chapter.</string>
|
||||
<string name="speed">Speed</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user