fix: 修复搜索范围选择行为
This commit is contained in:
1 parent
242fba70e8
commit
21f9409bd6
6 files changed
+166
-16
No files matched your search
@@ -1,8 +1,8 @@
|
|||||||
package io.legado.app.domain.model
|
package io.legado.app.domain.model
|
||||||
|
|
||||||
import com.google.gson.GsonBuilder
|
|
||||||
import io.legado.app.utils.fromJsonObject
|
|
||||||
import io.legado.app.utils.splitNotBlank
|
import io.legado.app.utils.splitNotBlank
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
data class BookSearchScope(val raw: String) {
|
data class BookSearchScope(val raw: String) {
|
||||||
|
|
||||||
@@ -39,11 +39,13 @@ data class BookSearchScope(val raw: String) {
|
|||||||
get() = groups.isEmpty() && sources.isEmpty()
|
get() = groups.isEmpty() && sources.isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class ScopeSourceItem(
|
data class ScopeSourceItem(
|
||||||
val name: String,
|
val name: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
private data class SerializedSearchScope(
|
private data class SerializedSearchScope(
|
||||||
val type: String = "",
|
val type: String = "",
|
||||||
val groups: List<String> = emptyList(),
|
val groups: List<String> = emptyList(),
|
||||||
@@ -57,7 +59,9 @@ data class BookSearchScope(val raw: String) {
|
|||||||
return if (selected.isEmpty()) {
|
return if (selected.isEmpty()) {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
scopeGson.toJson(SerializedSearchScope(type = TYPE_GROUP, groups = selected))
|
scopeJson.encodeToString(
|
||||||
|
SerializedSearchScope(type = TYPE_GROUP, groups = selected)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +70,9 @@ data class BookSearchScope(val raw: String) {
|
|||||||
return if (selected.isEmpty()) {
|
return if (selected.isEmpty()) {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
scopeGson.toJson(SerializedSearchScope(type = TYPE_SOURCE, sources = selected))
|
scopeJson.encodeToString(
|
||||||
|
SerializedSearchScope(type = TYPE_SOURCE, sources = selected)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +90,9 @@ data class BookSearchScope(val raw: String) {
|
|||||||
val json = raw.trim()
|
val json = raw.trim()
|
||||||
if (!json.startsWith("{") || !json.endsWith("}")) return null
|
if (!json.startsWith("{") || !json.endsWith("}")) return null
|
||||||
|
|
||||||
return scopeGson.fromJsonObject<SerializedSearchScope>(json).getOrNull()?.let { scope ->
|
return runCatching {
|
||||||
|
scopeJson.decodeFromString<SerializedSearchScope>(json)
|
||||||
|
}.getOrNull()?.let { scope ->
|
||||||
when (scope.type) {
|
when (scope.type) {
|
||||||
TYPE_SOURCE -> ParsedSearchScope(
|
TYPE_SOURCE -> ParsedSearchScope(
|
||||||
sources = scope.sources.filter { it.url.isNotBlank() }
|
sources = scope.sources.filter { it.url.isNotBlank() }
|
||||||
@@ -124,7 +132,10 @@ data class BookSearchScope(val raw: String) {
|
|||||||
|
|
||||||
private const val TYPE_GROUP = "group"
|
private const val TYPE_GROUP = "group"
|
||||||
private const val TYPE_SOURCE = "source"
|
private const val TYPE_SOURCE = "source"
|
||||||
private val scopeGson = GsonBuilder().disableHtmlEscaping().create()
|
private val scopeJson = Json {
|
||||||
|
encodeDefaults = false
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ import io.legado.app.R
|
|||||||
import io.legado.app.data.entities.BookSourcePart
|
import io.legado.app.data.entities.BookSourcePart
|
||||||
import io.legado.app.ui.theme.LegadoTheme
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
import io.legado.app.ui.widget.components.SearchBar
|
import io.legado.app.ui.widget.components.SearchBar
|
||||||
|
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
|
||||||
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
|
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
|
||||||
import io.legado.app.ui.widget.components.card.SelectionItemCard
|
import io.legado.app.ui.widget.components.card.SelectionItemCard
|
||||||
import io.legado.app.ui.widget.components.icon.AppIcons
|
import io.legado.app.ui.widget.components.icon.AppIcons
|
||||||
@@ -46,9 +47,20 @@ fun ScopeSelectSheet(
|
|||||||
isSourceScope: Boolean = false,
|
isSourceScope: Boolean = false,
|
||||||
title: String = stringResource(R.string.search_select_group),
|
title: String = stringResource(R.string.search_select_group),
|
||||||
onConfirm: (() -> Unit)? = null,
|
onConfirm: (() -> Unit)? = null,
|
||||||
|
onApplyScope: ((ScopeSelection) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var scopeSheetTab by rememberSaveable(show) { mutableIntStateOf(if (isSourceScope) 1 else 0) }
|
var scopeSheetTab by rememberSaveable(show) { mutableIntStateOf(if (isSourceScope) 1 else 0) }
|
||||||
var filterText by rememberSaveable(show) { mutableStateOf("") }
|
var filterText by rememberSaveable(show) { mutableStateOf("") }
|
||||||
|
var draftIsAll by remember(show, isAll) { mutableStateOf(isAll) }
|
||||||
|
var draftIsSourceScope by remember(show, isSourceScope) { mutableStateOf(isSourceScope) }
|
||||||
|
var draftGroups by remember(show, selectedGroups) { mutableStateOf(selectedGroups.toSet()) }
|
||||||
|
var draftSourceUrls by remember(show, selectedSources) { mutableStateOf(selectedSources.toSet()) }
|
||||||
|
|
||||||
|
val useDraftSelection = onApplyScope != null
|
||||||
|
val currentIsAll = if (useDraftSelection) draftIsAll else isAll
|
||||||
|
val currentIsSourceScope = if (useDraftSelection) draftIsSourceScope else isSourceScope
|
||||||
|
val currentGroups = if (useDraftSelection) draftGroups else selectedGroups
|
||||||
|
val currentSourceUrls = if (useDraftSelection) draftSourceUrls else selectedSources
|
||||||
|
|
||||||
val filteredGroups = remember(groups, filterText) {
|
val filteredGroups = remember(groups, filterText) {
|
||||||
if (filterText.isBlank()) groups else groups.filter { it.contains(filterText, ignoreCase = true) }
|
if (filterText.isBlank()) groups else groups.filter { it.contains(filterText, ignoreCase = true) }
|
||||||
@@ -87,11 +99,18 @@ fun ScopeSelectSheet(
|
|||||||
|
|
||||||
SelectionItemCard(
|
SelectionItemCard(
|
||||||
title = stringResource(R.string.all_source),
|
title = stringResource(R.string.all_source),
|
||||||
isSelected = isAll,
|
isSelected = currentIsAll,
|
||||||
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
||||||
inSelectionMode = true,
|
inSelectionMode = true,
|
||||||
onToggleSelection = {
|
onToggleSelection = {
|
||||||
onSelectAll()
|
if (useDraftSelection) {
|
||||||
|
draftIsAll = true
|
||||||
|
draftIsSourceScope = false
|
||||||
|
draftGroups = emptySet()
|
||||||
|
draftSourceUrls = emptySet()
|
||||||
|
} else {
|
||||||
|
onSelectAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,14 +132,27 @@ fun ScopeSelectSheet(
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
items(filteredGroups, key = { it }) { groupName ->
|
items(filteredGroups, key = { it }) { groupName ->
|
||||||
val selected = !isSourceScope && selectedGroups.contains(groupName)
|
val selected = !currentIsSourceScope && currentGroups.contains(groupName)
|
||||||
SelectionItemCard(
|
SelectionItemCard(
|
||||||
title = groupName,
|
title = groupName,
|
||||||
isSelected = selected,
|
isSelected = selected,
|
||||||
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
|
||||||
inSelectionMode = true,
|
inSelectionMode = true,
|
||||||
onToggleSelection = {
|
onToggleSelection = {
|
||||||
onToggleGroup(groupName)
|
if (useDraftSelection) {
|
||||||
|
val next = currentGroups.toMutableSet()
|
||||||
|
if (!currentIsSourceScope && next.contains(groupName)) {
|
||||||
|
next.remove(groupName)
|
||||||
|
} else {
|
||||||
|
next.add(groupName)
|
||||||
|
}
|
||||||
|
draftGroups = next
|
||||||
|
draftSourceUrls = emptySet()
|
||||||
|
draftIsSourceScope = false
|
||||||
|
draftIsAll = next.isEmpty()
|
||||||
|
} else {
|
||||||
|
onToggleGroup(groupName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,7 +174,7 @@ fun ScopeSelectSheet(
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
items(filteredSources, key = { it.bookSourceUrl }) { source ->
|
items(filteredSources, key = { it.bookSourceUrl }) { source ->
|
||||||
val selected = selectedSources.contains(source.bookSourceUrl)
|
val selected = currentSourceUrls.contains(source.bookSourceUrl)
|
||||||
SelectionItemCard(
|
SelectionItemCard(
|
||||||
title = source.bookSourceName,
|
title = source.bookSourceName,
|
||||||
subtitle = source.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
|
subtitle = source.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
|
||||||
@@ -150,7 +182,20 @@ fun ScopeSelectSheet(
|
|||||||
isSelected = selected,
|
isSelected = selected,
|
||||||
inSelectionMode = true,
|
inSelectionMode = true,
|
||||||
onToggleSelection = {
|
onToggleSelection = {
|
||||||
onToggleSource(source)
|
if (useDraftSelection) {
|
||||||
|
val next = currentSourceUrls.toMutableSet()
|
||||||
|
if (next.contains(source.bookSourceUrl)) {
|
||||||
|
next.remove(source.bookSourceUrl)
|
||||||
|
} else {
|
||||||
|
next.add(source.bookSourceUrl)
|
||||||
|
}
|
||||||
|
draftSourceUrls = next
|
||||||
|
draftGroups = emptySet()
|
||||||
|
draftIsSourceScope = next.isNotEmpty()
|
||||||
|
draftIsAll = next.isEmpty()
|
||||||
|
} else {
|
||||||
|
onToggleSource(source)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -158,7 +203,36 @@ fun ScopeSelectSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onApplyScope != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
ConfirmDismissButtonsRow(
|
||||||
|
onDismiss = onDismissRequest,
|
||||||
|
onConfirm = {
|
||||||
|
onApplyScope(
|
||||||
|
ScopeSelection(
|
||||||
|
groupNames = if (!draftIsSourceScope) draftGroups.toList() else emptyList(),
|
||||||
|
sources = if (draftIsSourceScope) {
|
||||||
|
sources.filter { draftSourceUrls.contains(it.bookSourceUrl) }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
isSourceScope = draftIsSourceScope,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
dismissText = stringResource(R.string.cancel),
|
||||||
|
confirmText = stringResource(R.string.confirm),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class ScopeSelection(
|
||||||
|
val groupNames: List<String>,
|
||||||
|
val sources: List<BookSourcePart>,
|
||||||
|
val isSourceScope: Boolean,
|
||||||
|
)
|
||||||
@@ -88,6 +88,11 @@ sealed interface SearchIntent {
|
|||||||
data class ToggleSourceType(val type: Int) : SearchIntent
|
data class ToggleSourceType(val type: Int) : SearchIntent
|
||||||
data object ClearAllSourceTypes : SearchIntent
|
data object ClearAllSourceTypes : SearchIntent
|
||||||
data object SelectAllScope : SearchIntent
|
data object SelectAllScope : SearchIntent
|
||||||
|
data class ApplyScopeSelection(
|
||||||
|
val groupNames: List<String>,
|
||||||
|
val sources: List<BookSourcePart>,
|
||||||
|
val isSourceScope: Boolean,
|
||||||
|
) : SearchIntent
|
||||||
data class ToggleScopeGroup(val groupName: String) : SearchIntent
|
data class ToggleScopeGroup(val groupName: String) : SearchIntent
|
||||||
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
|
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
|
||||||
data class RemoveScopeItem(val scopeName: String) : SearchIntent
|
data class RemoveScopeItem(val scopeName: String) : SearchIntent
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ data class SearchScope(private var scope: String) {
|
|||||||
if (scope.isEmpty()) {
|
if (scope.isEmpty()) {
|
||||||
return appCtx.getString(R.string.all_source)
|
return appCtx.getString(R.string.all_source)
|
||||||
}
|
}
|
||||||
return scope
|
return parsedScope().groupNames.joinToString(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ fun SearchScreen(
|
|||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
var queryInput by rememberSaveable { mutableStateOf(state.query) }
|
var queryInput by rememberSaveable { mutableStateOf(state.query) }
|
||||||
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var keepResultsPinnedToTop by rememberSaveable { mutableStateOf(true) }
|
||||||
val showSuggestionPanel = state.showSuggestions
|
val showSuggestionPanel = state.showSuggestions
|
||||||
val latestQuery by rememberUpdatedState(state.query)
|
val latestQuery by rememberUpdatedState(state.query)
|
||||||
val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
|
val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
|
||||||
@@ -243,6 +244,37 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.committedQuery) {
|
||||||
|
keepResultsPinnedToTop = true
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isSourceGroupedMode, listState, groupedListState) {
|
||||||
|
snapshotFlow {
|
||||||
|
val activeState = if (isSourceGroupedMode) groupedListState else listState
|
||||||
|
Triple(
|
||||||
|
activeState.firstVisibleItemIndex,
|
||||||
|
activeState.firstVisibleItemScrollOffset,
|
||||||
|
activeState.isScrollInProgress
|
||||||
|
)
|
||||||
|
}.collect { (index, offset, isScrollInProgress) ->
|
||||||
|
if (index == 0 && offset == 0) {
|
||||||
|
keepResultsPinnedToTop = true
|
||||||
|
} else if (isScrollInProgress) {
|
||||||
|
keepResultsPinnedToTop = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstResultKey = state.results.firstOrNull()?.let {
|
||||||
|
"${it.book.origin}:${it.book.bookUrl}"
|
||||||
|
}
|
||||||
|
LaunchedEffect(firstResultKey, state.results.size, state.isSearching) {
|
||||||
|
if (state.isSearching && keepResultsPinnedToTop && state.results.isNotEmpty()) {
|
||||||
|
listState.scrollToItem(0)
|
||||||
|
groupedListState.scrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val submitSearch: (String) -> Unit = { rawQuery ->
|
val submitSearch: (String) -> Unit = { rawQuery ->
|
||||||
val normalized = rawQuery.trim()
|
val normalized = rawQuery.trim()
|
||||||
if (normalized.isNotBlank()) {
|
if (normalized.isNotBlank()) {
|
||||||
@@ -611,6 +643,15 @@ fun SearchScreen(
|
|||||||
onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
|
onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
|
||||||
isSourceScope = state.isSourceScope,
|
isSourceScope = state.isSourceScope,
|
||||||
onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
|
onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
|
||||||
|
onApplyScope = { selection ->
|
||||||
|
viewModel.onIntent(
|
||||||
|
SearchIntent.ApplyScopeSelection(
|
||||||
|
groupNames = selection.groupNames,
|
||||||
|
sources = selection.sources,
|
||||||
|
isSourceScope = selection.isSourceScope,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
AppModalBottomSheet(
|
AppModalBottomSheet(
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ class SearchViewModel(
|
|||||||
syncScopeState(restartSearch = true, oldScope = oldScope)
|
syncScopeState(restartSearch = true, oldScope = oldScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is SearchIntent.ApplyScopeSelection -> applyScopeSelection(intent)
|
||||||
is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName)
|
is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName)
|
||||||
is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source)
|
is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source)
|
||||||
is SearchIntent.RemoveScopeItem -> {
|
is SearchIntent.RemoveScopeItem -> {
|
||||||
@@ -221,6 +222,7 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SearchIntent.SetMatchMode -> {
|
is SearchIntent.SetMatchMode -> {
|
||||||
|
_uiState.update { it.copy(matchMode = intent.mode) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localPreferencesRepository.updatePreference(
|
localPreferencesRepository.updatePreference(
|
||||||
LocalPreferencesKeys.MATCH_MODE, intent.mode.value
|
LocalPreferencesKeys.MATCH_MODE, intent.mode.value
|
||||||
@@ -427,7 +429,6 @@ class SearchViewModel(
|
|||||||
it.copy(
|
it.copy(
|
||||||
query = query,
|
query = query,
|
||||||
showSuggestions = showSuggestions,
|
showSuggestions = showSuggestions,
|
||||||
isManualStop = false,
|
|
||||||
emptyScopeAction = null,
|
emptyScopeAction = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -657,6 +658,7 @@ class SearchViewModel(
|
|||||||
_uiState.update { it.copy(emptyScopeAction = null) }
|
_uiState.update { it.copy(emptyScopeAction = null) }
|
||||||
|
|
||||||
if (action.wasMatchMode == MatchMode.EXACT) {
|
if (action.wasMatchMode == MatchMode.EXACT) {
|
||||||
|
_uiState.update { it.copy(matchMode = MatchMode.DEFAULT) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localPreferencesRepository.updatePreference(
|
localPreferencesRepository.updatePreference(
|
||||||
LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
|
LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
|
||||||
@@ -672,12 +674,29 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun restartCommittedSearchIfNeeded() {
|
private fun restartCommittedSearchIfNeeded() {
|
||||||
val committed = _uiState.value.committedQuery
|
val state = _uiState.value
|
||||||
if (committed.isNotBlank()) {
|
val committed = state.committedQuery
|
||||||
|
if (
|
||||||
|
committed.isNotBlank() &&
|
||||||
|
state.query.trim() == committed &&
|
||||||
|
!state.showSuggestions &&
|
||||||
|
!state.isManualStop
|
||||||
|
) {
|
||||||
submitSearch(committed)
|
submitSearch(committed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyScopeSelection(intent: SearchIntent.ApplyScopeSelection) {
|
||||||
|
val oldScope = searchScope.toString()
|
||||||
|
when {
|
||||||
|
intent.isSourceScope -> searchScope.updateSources(intent.sources)
|
||||||
|
intent.groupNames.isNotEmpty() -> searchScope.update(intent.groupNames)
|
||||||
|
else -> searchScope.update("")
|
||||||
|
}
|
||||||
|
persistSearchScope()
|
||||||
|
syncScopeState(restartSearch = true, oldScope = oldScope)
|
||||||
|
}
|
||||||
|
|
||||||
private fun syncScopeState(
|
private fun syncScopeState(
|
||||||
restartSearch: Boolean = false,
|
restartSearch: Boolean = false,
|
||||||
oldScope: String? = null,
|
oldScope: String? = null,
|
||||||
|
|||||||
Reference in new issue
Block a user