[新增] 为日后的迁移实现了一些与替换规则、书源管理有关的导入导出等可复用组件
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package io.legado.app.data.repository
|
||||
|
||||
import io.legado.app.help.DirectLinkUpload
|
||||
|
||||
interface UploadRepository {
|
||||
suspend fun upload(
|
||||
fileName: String,
|
||||
file: Any,
|
||||
contentType: String
|
||||
): String
|
||||
}
|
||||
|
||||
class DirectLinkUploadRepository : UploadRepository {
|
||||
|
||||
override suspend fun upload(
|
||||
fileName: String,
|
||||
file: Any,
|
||||
contentType: String
|
||||
): String {
|
||||
return DirectLinkUpload.upLoad(
|
||||
fileName = fileName,
|
||||
file = file,
|
||||
contentType = contentType
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package io.legado.app.di
|
||||
|
||||
import io.legado.app.data.AppDatabase
|
||||
import io.legado.app.data.repository.DirectLinkUploadRepository
|
||||
import io.legado.app.data.repository.ExploreRepository
|
||||
import io.legado.app.data.repository.ExploreRepositoryImpl
|
||||
import io.legado.app.data.repository.ReadRecordRepository
|
||||
import io.legado.app.data.repository.UploadRepository
|
||||
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
|
||||
import io.legado.app.ui.book.explore.ExploreShowViewModel
|
||||
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
|
||||
@@ -26,6 +28,8 @@ val appModule = module {
|
||||
single { ReadRecordRepository(get()) }
|
||||
viewModel { ReadRecordViewModel(get(), get(), get()) }
|
||||
|
||||
single<UploadRepository> { DirectLinkUploadRepository() }
|
||||
|
||||
// Explore
|
||||
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
||||
viewModel { ExploreShowViewModel(get()) }
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package io.legado.app.ui.widget.components.exportComponents
|
||||
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.SaveAlt
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legado.app.R
|
||||
|
||||
enum class FilePickerSheetMode {
|
||||
DIR, FILE, EXPORT
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FilePickerSheet(
|
||||
sheetState: SheetState,
|
||||
onDismissRequest: () -> Unit,
|
||||
mode: FilePickerSheetMode,
|
||||
onSelectSysDir: () -> Unit,
|
||||
onSelectSysFile: (Array<String>) -> Unit,
|
||||
onUpload: (() -> Unit)? = null,
|
||||
allowExtensions: Array<String>? = null,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.select_operation),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
when (mode) {
|
||||
FilePickerSheetMode.DIR -> {
|
||||
FilePickerOptionCard(
|
||||
icon = Icons.Default.FolderOpen,
|
||||
text = stringResource(R.string.sys_folder_picker),
|
||||
onClick = onSelectSysDir
|
||||
)
|
||||
}
|
||||
FilePickerSheetMode.FILE -> {
|
||||
FilePickerOptionCard(
|
||||
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
text = stringResource(R.string.sys_file_picker),
|
||||
onClick = { onSelectSysFile(typesOfExtensions(allowExtensions)) }
|
||||
)
|
||||
}
|
||||
FilePickerSheetMode.EXPORT -> {
|
||||
FilePickerOptionCard(
|
||||
icon = Icons.Default.SaveAlt,
|
||||
text = stringResource(R.string.save_to_local),
|
||||
onClick = onSelectSysDir
|
||||
)
|
||||
|
||||
if (onUpload != null) {
|
||||
FilePickerOptionCard(
|
||||
icon = Icons.Default.CloudUpload,
|
||||
text = stringResource(R.string.upload_url),
|
||||
onClick = onUpload
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.FilePickerOptionCard(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(100.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun typesOfExtensions(allowExtensions: Array<String>?): Array<String> {
|
||||
val types = hashSetOf<String>()
|
||||
if (allowExtensions.isNullOrEmpty()) {
|
||||
types.add("*/*")
|
||||
} else {
|
||||
allowExtensions.forEach {
|
||||
when (it) {
|
||||
"*" -> types.add("*/*")
|
||||
"txt", "xml" -> types.add("text/*")
|
||||
else -> {
|
||||
val mime = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(it)
|
||||
?: "application/octet-stream"
|
||||
types.add(mime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return types.toTypedArray()
|
||||
}
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
package io.legado.app.ui.widget.components.importComponents
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedToggleButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.legado.app.R
|
||||
import io.legado.app.ui.widget.components.AnimatedText
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SourceInputDialog(
|
||||
title: String = "网络导入",
|
||||
hint: String = "请输入 URL 或 JSON",
|
||||
initialValue: String = "",
|
||||
historyValues: List<String> = emptyList(), // 历史记录
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var text by remember { mutableStateOf(initialValue) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text(hint) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
if (historyValues.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("历史记录:", style = MaterialTheme.typography.labelSmall)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(historyValues) { history ->
|
||||
AssistChip(
|
||||
onClick = { text = history },
|
||||
label = { Text(history, maxLines = 1) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (text.isNotBlank()) onConfirm(text) }
|
||||
) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("ConfigurationScreenWidthHeight")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T> BatchImportDialog(
|
||||
title: String,
|
||||
importState: BaseImportUiState.Success<T>,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (List<T>) -> Unit, // 返回选中的原始数据列表
|
||||
onToggleItem: (index: Int) -> Unit,
|
||||
onToggleAll: (isSelected: Boolean) -> Unit,
|
||||
onItemInfoClick: (index: Int) -> Unit = {},
|
||||
// 插槽:允许调用方自定义顶部菜单
|
||||
topBarActions: @Composable RowScope.() -> Unit = {},
|
||||
// 插槽:自定义每一行的显示内容
|
||||
itemContent: @Composable (data: T, status: ImportStatus) -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val selectedCount = importState.items.count { it.isSelected }
|
||||
val totalCount = importState.items.size
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f),
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
AnimatedText(
|
||||
if (selectedCount > 0)
|
||||
stringResource(
|
||||
R.string.select_count,
|
||||
selectedCount,
|
||||
totalCount
|
||||
)
|
||||
else
|
||||
title
|
||||
)
|
||||
},
|
||||
actions = topBarActions,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
ImportBottomBar(
|
||||
selectedCount = selectedCount,
|
||||
totalCount = totalCount,
|
||||
onToggleSelectAll = { isAll -> onToggleAll(isAll) },
|
||||
onConfirm = {
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||
if (!sheetState.isVisible) {
|
||||
val selectedData =
|
||||
importState.items.filter { it.isSelected }.map { it.data }
|
||||
onConfirm(selectedData)
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||
if (!sheetState.isVisible) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(importState.items, key = { _, item -> item.data.hashCode() }) { index, itemWrapper ->
|
||||
ImportItemRow(
|
||||
isSelected = itemWrapper.isSelected,
|
||||
status = itemWrapper.status,
|
||||
onClick = { onToggleItem(index) },
|
||||
onInfoClick = {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar("其实还没写桀桀桀")
|
||||
}
|
||||
onItemInfoClick(index)
|
||||
},
|
||||
content = { itemContent(itemWrapper.data, itemWrapper.status) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImportItemRow(
|
||||
isSelected: Boolean,
|
||||
status: ImportStatus,
|
||||
onClick: () -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (isSelected)
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surface,
|
||||
animationSpec = tween(
|
||||
durationMillis = 200,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
label = "CardColor"
|
||||
)
|
||||
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
headlineContent = { content() },
|
||||
leadingContent = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = null
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = when (status) {
|
||||
ImportStatus.New -> "新增"
|
||||
ImportStatus.Update -> "更新"
|
||||
ImportStatus.Existing -> "已有"
|
||||
ImportStatus.Error -> "错误"
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = when (status) {
|
||||
ImportStatus.New -> MaterialTheme.colorScheme.primary
|
||||
ImportStatus.Update -> MaterialTheme.colorScheme.secondary
|
||||
ImportStatus.Error -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.outline
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = onInfoClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = "详情"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ImportBottomBar(
|
||||
selectedCount: Int,
|
||||
totalCount: Int,
|
||||
onToggleSelectAll: (Boolean) -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val allSelected = selectedCount == totalCount
|
||||
OutlinedToggleButton(
|
||||
checked = allSelected,
|
||||
onCheckedChange = { checked ->
|
||||
onToggleSelectAll(checked)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = if (allSelected) "全不选" else "全选"
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = onCancel) { Text("取消") }
|
||||
Button(
|
||||
enabled = selectedCount > 0,
|
||||
onClick = onConfirm
|
||||
) {
|
||||
Text("导入")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.legado.app.ui.widget.components.importComponents
|
||||
|
||||
// 单个条目的状态包装
|
||||
data class ImportItemWrapper<T>(
|
||||
val data: T,// 具体的数据对象 (ReplaceRule, BookSource, etc.)
|
||||
val oldData: T? = null,
|
||||
val isSelected: Boolean = true,
|
||||
val status: ImportStatus = ImportStatus.New // 用于UI显示颜色
|
||||
)
|
||||
|
||||
// 导入状态枚举
|
||||
enum class ImportStatus {
|
||||
New, // 新增 绿
|
||||
Update, // 更新 黄
|
||||
Existing, // 已有 灰
|
||||
Error // 错误 红
|
||||
}
|
||||
|
||||
// 整个导入流程的 UI State
|
||||
sealed interface BaseImportUiState<out T> {
|
||||
data object Idle : BaseImportUiState<Nothing>
|
||||
data object Loading : BaseImportUiState<Nothing>
|
||||
data class Error(val msg: String) : BaseImportUiState<Nothing>
|
||||
data class Success<T>(
|
||||
val source: String,
|
||||
val items: List<ImportItemWrapper<T>>,
|
||||
// 导入配置项
|
||||
val keepOriginalName: Boolean = false,
|
||||
val customGroup: String? = null,
|
||||
val isAddGroup: Boolean = false
|
||||
) : BaseImportUiState<T>
|
||||
}
|
||||
@@ -199,7 +199,7 @@
|
||||
<string name="replace_rule">替换规则</string>
|
||||
<string name="replace_to">替换为</string>
|
||||
<string name="img_cover">封面</string>
|
||||
<string name="book">本书</string>
|
||||
<string name="book">本书</string>
|
||||
<string name="volume_key_page">音量键翻页</string>
|
||||
<string name="mouse_wheel_page">鼠标滚轮翻页</string>
|
||||
<string name="click_turn_page">点击翻页</string>
|
||||
@@ -1364,4 +1364,4 @@
|
||||
<string name="edit_remark">编辑备注</string>
|
||||
<string name="book_remark">书籍备注</string>
|
||||
<string name="auto_check_new_backup_t">自动检查新备份</string>
|
||||
</resources>
|
||||
</resources>
|
||||
@@ -1367,4 +1367,6 @@
|
||||
<string name="enable_slider_vibrator">滑动进度条时震动</string>
|
||||
<string name="edit_remark">编辑备注</string>
|
||||
<string name="book_remark">书籍备注</string>
|
||||
<string name="save_to_local">保存至本地</string>
|
||||
<string name="select_operation">选择操作</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user