mirror of
https://github.com/mihonapp/mihon.git
synced 2025-11-07 09:38:59 +01:00
chore: review pointers.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package tachiyomi.domain.sync
|
||||
package eu.kanade.domain.sync
|
||||
|
||||
import eu.kanade.domain.sync.models.SyncSettings
|
||||
import eu.kanade.tachiyomi.data.sync.models.SyncTriggerOptions
|
||||
import tachiyomi.core.preference.Preference
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import java.util.UUID
|
||||
@@ -7,28 +9,8 @@ import java.util.UUID
|
||||
class SyncPreferences(
|
||||
private val preferenceStore: PreferenceStore,
|
||||
) {
|
||||
object Flags {
|
||||
const val NONE = 0x0
|
||||
const val SYNC_ON_CHAPTER_READ = 0x1
|
||||
const val SYNC_ON_CHAPTER_OPEN = 0x2
|
||||
const val SYNC_ON_APP_START = 0x4
|
||||
const val SYNC_ON_APP_RESUME = 0x8
|
||||
const val SYNC_ON_LIBRARY_UPDATE = 0x10
|
||||
|
||||
const val Defaults = NONE
|
||||
|
||||
fun values() = listOf(
|
||||
NONE,
|
||||
SYNC_ON_CHAPTER_READ,
|
||||
SYNC_ON_CHAPTER_OPEN,
|
||||
SYNC_ON_APP_START,
|
||||
SYNC_ON_APP_RESUME,
|
||||
SYNC_ON_LIBRARY_UPDATE,
|
||||
)
|
||||
}
|
||||
|
||||
fun syncHost() = preferenceStore.getString("sync_host", "https://sync.tachiyomi.org")
|
||||
fun syncAPIKey() = preferenceStore.getString("sync_api_key", "")
|
||||
fun clientHost() = preferenceStore.getString("sync_client_host", "https://sync.tachiyomi.org")
|
||||
fun clientAPIKey() = preferenceStore.getString("sync_client_api_key", "")
|
||||
fun lastSyncTimestamp() = preferenceStore.getLong(Preference.appStateKey("last_sync_timestamp"), 0L)
|
||||
|
||||
fun syncInterval() = preferenceStore.getInt("sync_interval", 0)
|
||||
@@ -57,14 +39,12 @@ class SyncPreferences(
|
||||
return uniqueID
|
||||
}
|
||||
|
||||
fun syncFlags() = preferenceStore.getInt("sync_flags", Flags.Defaults)
|
||||
|
||||
fun isSyncEnabled(): Boolean {
|
||||
return syncService().get() != 0
|
||||
}
|
||||
|
||||
fun getSyncOptions(): SyncOptions {
|
||||
return SyncOptions(
|
||||
fun getSyncSettings(): SyncSettings {
|
||||
return SyncSettings(
|
||||
libraryEntries = preferenceStore.getBoolean("library_entries", true).get(),
|
||||
categories = preferenceStore.getBoolean("categories", true).get(),
|
||||
chapters = preferenceStore.getBoolean("chapters", true).get(),
|
||||
@@ -76,25 +56,37 @@ class SyncPreferences(
|
||||
)
|
||||
}
|
||||
|
||||
fun setSyncOptions(syncOptions: SyncOptions) {
|
||||
preferenceStore.getBoolean("library_entries", true).set(syncOptions.libraryEntries)
|
||||
preferenceStore.getBoolean("categories", true).set(syncOptions.categories)
|
||||
preferenceStore.getBoolean("chapters", true).set(syncOptions.chapters)
|
||||
preferenceStore.getBoolean("tracking", true).set(syncOptions.tracking)
|
||||
preferenceStore.getBoolean("history", true).set(syncOptions.history)
|
||||
preferenceStore.getBoolean("appSettings", true).set(syncOptions.appSettings)
|
||||
preferenceStore.getBoolean("sourceSettings", true).set(syncOptions.sourceSettings)
|
||||
preferenceStore.getBoolean("privateSettings", true).set(syncOptions.privateSettings)
|
||||
fun setSyncSettings(syncSettings: SyncSettings) {
|
||||
preferenceStore.getBoolean("library_entries", true).set(syncSettings.libraryEntries)
|
||||
preferenceStore.getBoolean("categories", true).set(syncSettings.categories)
|
||||
preferenceStore.getBoolean("chapters", true).set(syncSettings.chapters)
|
||||
preferenceStore.getBoolean("tracking", true).set(syncSettings.tracking)
|
||||
preferenceStore.getBoolean("history", true).set(syncSettings.history)
|
||||
preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings)
|
||||
preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings)
|
||||
preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings)
|
||||
}
|
||||
|
||||
fun getSyncTriggerOptions(): SyncTriggerOptions {
|
||||
return SyncTriggerOptions(
|
||||
syncOnChapterRead = preferenceStore.getBoolean("sync_on_chapter_read", false).get(),
|
||||
syncOnChapterOpen = preferenceStore.getBoolean("sync_on_chapter_open", false).get(),
|
||||
syncOnAppStart = preferenceStore.getBoolean("sync_on_app_start", false).get(),
|
||||
syncOnAppResume = preferenceStore.getBoolean("sync_on_app_resume", false).get(),
|
||||
syncOnLibraryUpdate = preferenceStore.getBoolean("sync_on_library_update", false).get(),
|
||||
)
|
||||
}
|
||||
|
||||
fun setSyncTriggerOptions(syncTriggerOptions: SyncTriggerOptions) {
|
||||
preferenceStore.getBoolean("sync_on_chapter_read", false)
|
||||
.set(syncTriggerOptions.syncOnChapterRead)
|
||||
preferenceStore.getBoolean("sync_on_chapter_open", false)
|
||||
.set(syncTriggerOptions.syncOnChapterOpen)
|
||||
preferenceStore.getBoolean("sync_on_app_start", false)
|
||||
.set(syncTriggerOptions.syncOnAppStart)
|
||||
preferenceStore.getBoolean("sync_on_app_resume", false)
|
||||
.set(syncTriggerOptions.syncOnAppResume)
|
||||
preferenceStore.getBoolean("sync_on_library_update", false)
|
||||
.set(syncTriggerOptions.syncOnLibraryUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
data class SyncOptions(
|
||||
val libraryEntries: Boolean = true,
|
||||
val categories: Boolean = true,
|
||||
val chapters: Boolean = true,
|
||||
val tracking: Boolean = true,
|
||||
val history: Boolean = true,
|
||||
val appSettings: Boolean = true,
|
||||
val sourceSettings: Boolean = true,
|
||||
val privateSettings: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package eu.kanade.domain.sync.models
|
||||
|
||||
data class SyncSettings(
|
||||
val libraryEntries: Boolean = true,
|
||||
val categories: Boolean = true,
|
||||
val chapters: Boolean = true,
|
||||
val tracking: Boolean = true,
|
||||
val history: Boolean = true,
|
||||
val appSettings: Boolean = true,
|
||||
val sourceSettings: Boolean = true,
|
||||
val privateSettings: Boolean = false,
|
||||
)
|
||||
@@ -1,125 +0,0 @@
|
||||
package eu.kanade.presentation.more.settings.screen.data
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import kotlinx.collections.immutable.PersistentSet
|
||||
import kotlinx.collections.immutable.minus
|
||||
import kotlinx.collections.immutable.plus
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.domain.sync.SyncPreferences
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.LabeledCheckbox
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SyncOptionsScreen : Screen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val model = rememberScreenModel { SyncOptionsScreenModel() }
|
||||
val state by model.state.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppBar(
|
||||
title = stringResource(MR.strings.pref_sync_options),
|
||||
navigateUp = navigator::pop,
|
||||
scrollBehavior = it,
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = MaterialTheme.padding.medium),
|
||||
) {
|
||||
SyncChoices.forEach { (k, v) ->
|
||||
item {
|
||||
LabeledCheckbox(
|
||||
label = stringResource(v),
|
||||
checked = state.flags.contains(k),
|
||||
onCheckedChange = {
|
||||
model.toggleOptionFlag(k)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SyncOptionsScreenModel : StateScreenModel<SyncOptionsScreenModel.State>(State()) {
|
||||
private val syncPreferences = Injekt.get<SyncPreferences>()
|
||||
|
||||
init {
|
||||
loadInitialFlags()
|
||||
}
|
||||
|
||||
private fun loadInitialFlags() {
|
||||
val savedFlags = syncPreferences.syncFlags().get()
|
||||
val flagSet = SyncPreferences.Flags.values().filter { flag ->
|
||||
savedFlags and flag > 0
|
||||
}.toSet().toPersistentSet()
|
||||
|
||||
mutableState.update { State(flags = flagSet) }
|
||||
}
|
||||
|
||||
fun toggleOptionFlag(option: Int) {
|
||||
mutableState.update { currentState ->
|
||||
val newFlags = if (currentState.flags.contains(option)) {
|
||||
currentState.flags - option
|
||||
} else {
|
||||
currentState.flags + option
|
||||
}
|
||||
saveFlags(newFlags)
|
||||
currentState.copy(flags = newFlags)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveFlags(flags: PersistentSet<Int>) {
|
||||
val flagsInt = flags.fold(0) { acc, flag -> acc or flag }
|
||||
syncPreferences.syncFlags().set(flagsInt)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val flags: PersistentSet<Int> = SyncChoices.keys.toPersistentSet(),
|
||||
)
|
||||
}
|
||||
|
||||
private val SyncChoices = mapOf(
|
||||
SyncPreferences.Flags.SYNC_ON_CHAPTER_READ to MR.strings.sync_on_chapter_read,
|
||||
SyncPreferences.Flags.SYNC_ON_CHAPTER_OPEN to MR.strings.sync_on_chapter_open,
|
||||
SyncPreferences.Flags.SYNC_ON_APP_START to MR.strings.sync_on_app_start,
|
||||
SyncPreferences.Flags.SYNC_ON_APP_RESUME to MR.strings.sync_on_app_resume,
|
||||
SyncPreferences.Flags.SYNC_ON_LIBRARY_UPDATE to MR.strings.sync_on_library_update,
|
||||
)
|
||||
@@ -10,6 +10,8 @@ import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.domain.sync.SyncPreferences
|
||||
import eu.kanade.domain.sync.models.SyncSettings
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
|
||||
@@ -17,8 +19,6 @@ import eu.kanade.tachiyomi.data.sync.SyncDataJob
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.domain.sync.SyncOptions
|
||||
import tachiyomi.domain.sync.SyncPreferences
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.LabeledCheckbox
|
||||
import tachiyomi.presentation.core.components.LazyColumnWithAction
|
||||
@@ -95,12 +95,12 @@ class SyncSettingsSelector : Screen() {
|
||||
private class SyncSettingsSelectorModel(
|
||||
val syncPreferences: SyncPreferences = Injekt.get(),
|
||||
) : StateScreenModel<SyncSettingsSelectorModel.State>(
|
||||
State(syncOptionsToBackupOptions(syncPreferences.getSyncOptions())),
|
||||
State(syncOptionsToBackupOptions(syncPreferences.getSyncSettings())),
|
||||
) {
|
||||
fun toggle(setter: (BackupOptions, Boolean) -> BackupOptions, enabled: Boolean) {
|
||||
mutableState.update {
|
||||
val updatedOptions = setter(it.options, enabled)
|
||||
syncPreferences.setSyncOptions(backupOptionsToSyncOptions(updatedOptions))
|
||||
syncPreferences.setSyncSettings(backupOptionsToSyncOptions(updatedOptions))
|
||||
it.copy(options = updatedOptions)
|
||||
}
|
||||
}
|
||||
@@ -113,21 +113,21 @@ private class SyncSettingsSelectorModel(
|
||||
data class State(
|
||||
val options: BackupOptions = BackupOptions(),
|
||||
) companion object {
|
||||
private fun syncOptionsToBackupOptions(syncOptions: SyncOptions): BackupOptions {
|
||||
private fun syncOptionsToBackupOptions(syncSettings: SyncSettings): BackupOptions {
|
||||
return BackupOptions(
|
||||
libraryEntries = syncOptions.libraryEntries,
|
||||
categories = syncOptions.categories,
|
||||
chapters = syncOptions.chapters,
|
||||
tracking = syncOptions.tracking,
|
||||
history = syncOptions.history,
|
||||
appSettings = syncOptions.appSettings,
|
||||
sourceSettings = syncOptions.sourceSettings,
|
||||
privateSettings = syncOptions.privateSettings,
|
||||
libraryEntries = syncSettings.libraryEntries,
|
||||
categories = syncSettings.categories,
|
||||
chapters = syncSettings.chapters,
|
||||
tracking = syncSettings.tracking,
|
||||
history = syncSettings.history,
|
||||
appSettings = syncSettings.appSettings,
|
||||
sourceSettings = syncSettings.sourceSettings,
|
||||
privateSettings = syncSettings.privateSettings,
|
||||
)
|
||||
}
|
||||
|
||||
private fun backupOptionsToSyncOptions(backupOptions: BackupOptions): SyncOptions {
|
||||
return SyncOptions(
|
||||
private fun backupOptionsToSyncOptions(backupOptions: BackupOptions): SyncSettings {
|
||||
return SyncSettings(
|
||||
libraryEntries = backupOptions.libraryEntries,
|
||||
categories = backupOptions.categories,
|
||||
chapters = backupOptions.chapters,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package eu.kanade.presentation.more.settings.screen.data
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.domain.sync.SyncPreferences
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.data.sync.models.SyncTriggerOptions
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.LabeledCheckbox
|
||||
import tachiyomi.presentation.core.components.LazyColumnWithAction
|
||||
import tachiyomi.presentation.core.components.SectionCard
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SyncTriggerOptionsScreen : Screen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val model = rememberScreenModel { SyncOptionsScreenModel() }
|
||||
val state by model.state.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppBar(
|
||||
title = stringResource(MR.strings.pref_sync_options),
|
||||
navigateUp = navigator::pop,
|
||||
scrollBehavior = it,
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
LazyColumnWithAction(
|
||||
contentPadding = contentPadding,
|
||||
actionLabel = stringResource(MR.strings.action_save),
|
||||
actionEnabled = state.options.anyEnabled(),
|
||||
onClickAction = {
|
||||
navigator.pop()
|
||||
},
|
||||
) {
|
||||
item {
|
||||
SectionCard(MR.strings.label_triggers) {
|
||||
Options(SyncTriggerOptions.mainOptions, state, model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Options(
|
||||
options: ImmutableList<SyncTriggerOptions.Entry>,
|
||||
state: SyncOptionsScreenModel.State,
|
||||
model: SyncOptionsScreenModel,
|
||||
) {
|
||||
options.forEach { option ->
|
||||
LabeledCheckbox(
|
||||
label = stringResource(option.label),
|
||||
checked = option.getter(state.options),
|
||||
onCheckedChange = {
|
||||
model.toggle(option.setter, it)
|
||||
},
|
||||
enabled = option.enabled(state.options),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SyncOptionsScreenModel(
|
||||
val syncPreferences: SyncPreferences = Injekt.get(),
|
||||
) : StateScreenModel<SyncOptionsScreenModel.State>(
|
||||
State(
|
||||
syncPreferences.getSyncTriggerOptions(),
|
||||
),
|
||||
) {
|
||||
|
||||
fun toggle(setter: (SyncTriggerOptions, Boolean) -> SyncTriggerOptions, enabled: Boolean) {
|
||||
mutableState.update {
|
||||
val updatedTriggerOptions = setter(it.options, enabled)
|
||||
syncPreferences.setSyncTriggerOptions(updatedTriggerOptions)
|
||||
it.copy(
|
||||
options = updatedTriggerOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val options: SyncTriggerOptions = SyncTriggerOptions(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package eu.kanade.tachiyomi.data.sync.models
|
||||
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import tachiyomi.i18n.MR
|
||||
|
||||
data class SyncTriggerOptions(
|
||||
val syncOnChapterRead: Boolean = false,
|
||||
val syncOnChapterOpen: Boolean = false,
|
||||
val syncOnAppStart: Boolean = false,
|
||||
val syncOnAppResume: Boolean = false,
|
||||
val syncOnLibraryUpdate: Boolean = false,
|
||||
) {
|
||||
fun asBooleanArray() = booleanArrayOf(
|
||||
syncOnChapterRead,
|
||||
syncOnChapterOpen,
|
||||
syncOnAppStart,
|
||||
syncOnAppResume,
|
||||
syncOnLibraryUpdate,
|
||||
)
|
||||
|
||||
fun anyEnabled() = syncOnChapterRead ||
|
||||
syncOnChapterOpen ||
|
||||
syncOnAppStart ||
|
||||
syncOnAppResume ||
|
||||
syncOnLibraryUpdate
|
||||
|
||||
companion object {
|
||||
val mainOptions = persistentListOf(
|
||||
Entry(
|
||||
label = MR.strings.sync_on_chapter_read,
|
||||
getter = SyncTriggerOptions::syncOnChapterRead,
|
||||
setter = { options, enabled -> options.copy(syncOnChapterRead = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.sync_on_chapter_open,
|
||||
getter = SyncTriggerOptions::syncOnChapterOpen,
|
||||
setter = { options, enabled -> options.copy(syncOnChapterOpen = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.sync_on_app_start,
|
||||
getter = SyncTriggerOptions::syncOnAppStart,
|
||||
setter = { options, enabled -> options.copy(syncOnAppStart = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.sync_on_app_resume,
|
||||
getter = SyncTriggerOptions::syncOnAppResume,
|
||||
setter = { options, enabled -> options.copy(syncOnAppResume = enabled) },
|
||||
),
|
||||
Entry(
|
||||
label = MR.strings.sync_on_library_update,
|
||||
getter = SyncTriggerOptions::syncOnLibraryUpdate,
|
||||
setter = { options, enabled -> options.copy(syncOnLibraryUpdate = enabled) },
|
||||
),
|
||||
)
|
||||
|
||||
fun fromBooleanArray(array: BooleanArray) = SyncTriggerOptions(
|
||||
syncOnChapterRead = array[0],
|
||||
syncOnChapterOpen = array[1],
|
||||
syncOnAppStart = array[2],
|
||||
syncOnAppResume = array[3],
|
||||
syncOnLibraryUpdate = array[4],
|
||||
)
|
||||
}
|
||||
|
||||
data class Entry(
|
||||
val label: StringResource,
|
||||
val getter: (SyncTriggerOptions) -> Boolean,
|
||||
val setter: (SyncTriggerOptions, Boolean) -> SyncTriggerOptions,
|
||||
val enabled: (SyncTriggerOptions) -> Boolean = { true },
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user