mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-22 15:01:52 +01:00
Use custom QueryPagingSource (#7321)
* Use custom QueryPagingSource - Adds placeholder to make the list jump around less - Fixes issue where SQLDelight QueryPagingSource would throw IndexOutOfBounds * Review Changes
This commit is contained in:
parent
4c3af7bf36
commit
3fd9e021fa
@ -2,8 +2,6 @@ package eu.kanade.data
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import com.squareup.sqldelight.Query
|
||||
import com.squareup.sqldelight.Transacter
|
||||
import com.squareup.sqldelight.android.paging3.QueryPagingSource
|
||||
import com.squareup.sqldelight.db.SqlDriver
|
||||
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
||||
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
||||
@ -63,13 +61,11 @@ class AndroidDatabaseHandler(
|
||||
|
||||
override fun <T : Any> subscribeToPagingSource(
|
||||
countQuery: Database.() -> Query<Long>,
|
||||
transacter: Database.() -> Transacter,
|
||||
queryProvider: Database.(Long, Long) -> Query<T>,
|
||||
): PagingSource<Long, T> {
|
||||
return QueryPagingSource(
|
||||
countQuery = countQuery(db),
|
||||
transacter = transacter(db),
|
||||
dispatcher = queryDispatcher,
|
||||
handler = this,
|
||||
countQuery = countQuery,
|
||||
queryProvider = { limit, offset ->
|
||||
queryProvider.invoke(db, limit, offset)
|
||||
},
|
||||
|
@ -2,7 +2,6 @@ package eu.kanade.data
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import com.squareup.sqldelight.Query
|
||||
import com.squareup.sqldelight.Transacter
|
||||
import eu.kanade.tachiyomi.Database
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@ -33,7 +32,6 @@ interface DatabaseHandler {
|
||||
|
||||
fun <T : Any> subscribeToPagingSource(
|
||||
countQuery: Database.() -> Query<Long>,
|
||||
transacter: Database.() -> Transacter,
|
||||
queryProvider: Database.(Long, Long) -> Query<T>,
|
||||
): PagingSource<Long, T>
|
||||
}
|
||||
|
72
app/src/main/java/eu/kanade/data/QueryPagingSource.kt
Normal file
72
app/src/main/java/eu/kanade/data/QueryPagingSource.kt
Normal file
@ -0,0 +1,72 @@
|
||||
package eu.kanade.data
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.squareup.sqldelight.Query
|
||||
import eu.kanade.tachiyomi.Database
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
class QueryPagingSource<RowType : Any>(
|
||||
val handler: DatabaseHandler,
|
||||
val countQuery: Database.() -> Query<Long>,
|
||||
val queryProvider: Database.(Long, Long) -> Query<RowType>,
|
||||
) : PagingSource<Long, RowType>(), Query.Listener {
|
||||
|
||||
override val jumpingSupported: Boolean = true
|
||||
|
||||
private var currentQuery: Query<RowType>? by Delegates.observable(null) { _, old, new ->
|
||||
old?.removeListener(this)
|
||||
new?.addListener(this)
|
||||
}
|
||||
|
||||
init {
|
||||
registerInvalidatedCallback {
|
||||
currentQuery?.removeListener(this)
|
||||
currentQuery = null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, RowType> {
|
||||
try {
|
||||
val key = params.key ?: 0L
|
||||
val loadSize = params.loadSize
|
||||
val count = handler.awaitOne { countQuery() }
|
||||
|
||||
val (offset, limit) = when (params) {
|
||||
is LoadParams.Prepend -> key - loadSize to loadSize.toLong()
|
||||
else -> key to loadSize.toLong()
|
||||
}
|
||||
|
||||
val data = handler.awaitList {
|
||||
queryProvider(limit, offset)
|
||||
.also { currentQuery = it }
|
||||
}
|
||||
|
||||
val (prevKey, nextKey) = when (params) {
|
||||
is LoadParams.Append -> { offset - loadSize to offset + loadSize }
|
||||
else -> { offset to offset + loadSize }
|
||||
}
|
||||
|
||||
return LoadResult.Page(
|
||||
data = data,
|
||||
prevKey = if (offset <= 0L || prevKey < 0L) null else prevKey,
|
||||
nextKey = if (offset + loadSize >= count) null else nextKey,
|
||||
itemsBefore = maxOf(0L, offset).toInt(),
|
||||
itemsAfter = maxOf(0L, count - (offset + loadSize)).toInt(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return LoadResult.Error(throwable = e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Long, RowType>): Long? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
val anchorPage = state.closestPageToPosition(anchorPosition)
|
||||
anchorPage?.prevKey ?: anchorPage?.nextKey
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryResultsChanged() {
|
||||
invalidate()
|
||||
}
|
||||
}
|
@ -19,7 +19,6 @@ class HistoryRepositoryImpl(
|
||||
override fun getHistory(query: String): PagingSource<Long, HistoryWithRelations> {
|
||||
return handler.subscribeToPagingSource(
|
||||
countQuery = { historyViewQueries.countHistory(query) },
|
||||
transacter = { historyViewQueries },
|
||||
queryProvider = { limit, offset ->
|
||||
historyViewQueries.history(query, limit, offset, historyWithRelationsMapper)
|
||||
},
|
||||
|
@ -14,7 +14,7 @@ import coil.compose.AsyncImage
|
||||
import eu.kanade.presentation.util.rememberResourceBitmapPainter
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
enum class MangaCover(private val ratio: Float) {
|
||||
enum class MangaCover(val ratio: Float) {
|
||||
Square(1f / 1f),
|
||||
Book(2f / 3f);
|
||||
|
||||
|
@ -1,24 +1,21 @@
|
||||
package eu.kanade.presentation.history
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -29,12 +26,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush.Companion.linearGradient
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
@ -43,22 +39,20 @@ import androidx.paging.compose.items
|
||||
import eu.kanade.domain.history.model.HistoryWithRelations
|
||||
import eu.kanade.presentation.components.EmptyScreen
|
||||
import eu.kanade.presentation.components.LoadingScreen
|
||||
import eu.kanade.presentation.components.MangaCover
|
||||
import eu.kanade.presentation.components.ScrollbarLazyColumn
|
||||
import eu.kanade.presentation.util.horizontalPadding
|
||||
import eu.kanade.presentation.history.components.HistoryHeader
|
||||
import eu.kanade.presentation.history.components.HistoryItem
|
||||
import eu.kanade.presentation.history.components.HistoryItemShimmer
|
||||
import eu.kanade.presentation.util.plus
|
||||
import eu.kanade.presentation.util.shimmerGradient
|
||||
import eu.kanade.presentation.util.topPaddingValues
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter
|
||||
import eu.kanade.tachiyomi.ui.recent.history.HistoryState
|
||||
import eu.kanade.tachiyomi.util.lang.toRelativeString
|
||||
import eu.kanade.tachiyomi.util.lang.toTimestampString
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.DateFormat
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
@ -93,10 +87,7 @@ fun HistoryContent(
|
||||
preferences: PreferencesHelper = Injekt.get(),
|
||||
nestedScroll: NestedScrollConnection,
|
||||
) {
|
||||
if (history.loadState.refresh is LoadState.Loading) {
|
||||
LoadingScreen()
|
||||
return
|
||||
} else if (history.loadState.refresh is LoadState.NotLoading && history.itemCount == 0) {
|
||||
if (history.loadState.refresh is LoadState.NotLoading && history.itemCount == 0) {
|
||||
EmptyScreen(textResource = R.string.information_no_recent_manga)
|
||||
return
|
||||
}
|
||||
@ -107,6 +98,29 @@ fun HistoryContent(
|
||||
var removeState by remember { mutableStateOf<HistoryWithRelations?>(null) }
|
||||
|
||||
val scrollState = rememberLazyListState()
|
||||
|
||||
val transition = rememberInfiniteTransition()
|
||||
|
||||
val translateAnimation = transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1000f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = 1000,
|
||||
easing = LinearEasing,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val brush = linearGradient(
|
||||
colors = shimmerGradient,
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(
|
||||
x = translateAnimation.value,
|
||||
y = 00f,
|
||||
),
|
||||
)
|
||||
|
||||
ScrollbarLazyColumn(
|
||||
modifier = Modifier
|
||||
.nestedScroll(nestedScroll),
|
||||
@ -134,7 +148,9 @@ fun HistoryContent(
|
||||
onClickDelete = { removeState = value },
|
||||
)
|
||||
}
|
||||
null -> {}
|
||||
null -> {
|
||||
HistoryItemShimmer(brush = brush)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -150,88 +166,6 @@ fun HistoryContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HistoryHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
date: Date,
|
||||
relativeTime: Int,
|
||||
dateFormat: DateFormat,
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier
|
||||
.padding(horizontal = horizontalPadding, vertical = 8.dp),
|
||||
text = date.toRelativeString(
|
||||
LocalContext.current,
|
||||
relativeTime,
|
||||
dateFormat,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HistoryItem(
|
||||
modifier: Modifier = Modifier,
|
||||
history: HistoryWithRelations,
|
||||
onClickCover: () -> Unit,
|
||||
onClickResume: () -> Unit,
|
||||
onClickDelete: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClickResume)
|
||||
.height(96.dp)
|
||||
.padding(horizontal = horizontalPadding, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MangaCover.Book(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.clickable(onClick = onClickCover),
|
||||
data = history.coverData,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = horizontalPadding, end = 8.dp),
|
||||
) {
|
||||
val textStyle = MaterialTheme.typography.bodyMedium
|
||||
Text(
|
||||
text = history.title,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = textStyle.copy(fontWeight = FontWeight.SemiBold),
|
||||
)
|
||||
Row {
|
||||
Text(
|
||||
text = if (history.chapterNumber > -1) {
|
||||
stringResource(
|
||||
R.string.recent_manga_time,
|
||||
chapterFormatter.format(history.chapterNumber),
|
||||
history.readAt?.toTimestampString() ?: "",
|
||||
)
|
||||
} else {
|
||||
history.readAt?.toTimestampString() ?: ""
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
style = textStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onClickDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoveHistoryDialog(
|
||||
onPositive: (Boolean) -> Unit,
|
||||
@ -282,11 +216,6 @@ fun RemoveHistoryDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private val chapterFormatter = DecimalFormat(
|
||||
"#.###",
|
||||
DecimalFormatSymbols().apply { decimalSeparator = '.' },
|
||||
)
|
||||
|
||||
sealed class HistoryUiModel {
|
||||
data class Header(val date: Date) : HistoryUiModel()
|
||||
data class Item(val item: HistoryWithRelations) : HistoryUiModel()
|
||||
|
@ -0,0 +1,36 @@
|
||||
package eu.kanade.presentation.history.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.util.horizontalPadding
|
||||
import eu.kanade.tachiyomi.util.lang.toRelativeString
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
fun HistoryHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
date: Date,
|
||||
relativeTime: Int,
|
||||
dateFormat: DateFormat,
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier
|
||||
.padding(horizontal = horizontalPadding, vertical = 8.dp),
|
||||
text = date.toRelativeString(
|
||||
LocalContext.current,
|
||||
relativeTime,
|
||||
dateFormat,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
}
|
@ -0,0 +1,143 @@
|
||||
package eu.kanade.presentation.history.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.domain.history.model.HistoryWithRelations
|
||||
import eu.kanade.presentation.components.MangaCover
|
||||
import eu.kanade.presentation.util.horizontalPadding
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.lang.toTimestampString
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
|
||||
private val HISTORY_ITEM_HEIGHT = 96.dp
|
||||
|
||||
@Composable
|
||||
fun HistoryItem(
|
||||
modifier: Modifier = Modifier,
|
||||
history: HistoryWithRelations,
|
||||
onClickCover: () -> Unit,
|
||||
onClickResume: () -> Unit,
|
||||
onClickDelete: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClickResume)
|
||||
.height(HISTORY_ITEM_HEIGHT)
|
||||
.padding(horizontal = horizontalPadding, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MangaCover.Book(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.clickable(onClick = onClickCover),
|
||||
data = history.coverData,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = horizontalPadding, end = 8.dp),
|
||||
) {
|
||||
val textStyle = MaterialTheme.typography.bodyMedium
|
||||
Text(
|
||||
text = history.title,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = textStyle.copy(fontWeight = FontWeight.SemiBold),
|
||||
)
|
||||
Text(
|
||||
text = if (history.chapterNumber > -1) {
|
||||
stringResource(
|
||||
R.string.recent_manga_time,
|
||||
chapterFormatter.format(history.chapterNumber),
|
||||
history.readAt?.toTimestampString() ?: "",
|
||||
)
|
||||
} else {
|
||||
history.readAt?.toTimestampString() ?: ""
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
style = textStyle,
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onClickDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HistoryItemShimmer(brush: Brush) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(HISTORY_ITEM_HEIGHT)
|
||||
.padding(horizontal = horizontalPadding, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(MangaCover.Book.ratio)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.drawBehind {
|
||||
drawRect(brush = brush)
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = horizontalPadding, end = 8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.drawBehind {
|
||||
drawRect(brush = brush)
|
||||
}
|
||||
.height(14.dp)
|
||||
.fillMaxWidth(0.70f),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.height(14.dp)
|
||||
.fillMaxWidth(0.45f)
|
||||
.drawBehind {
|
||||
drawRect(brush = brush)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val chapterFormatter = DecimalFormat(
|
||||
"#.###",
|
||||
DecimalFormatSymbols().apply { decimalSeparator = '.' },
|
||||
)
|
9
app/src/main/java/eu/kanade/presentation/util/Shimmer.kt
Normal file
9
app/src/main/java/eu/kanade/presentation/util/Shimmer.kt
Normal file
@ -0,0 +1,9 @@
|
||||
package eu.kanade.presentation.util
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val shimmerGradient = listOf(
|
||||
Color.LightGray.copy(alpha = 0.8f),
|
||||
Color.LightGray.copy(alpha = 0.2f),
|
||||
Color.LightGray.copy(alpha = 0.8f),
|
||||
)
|
Loading…
Reference in New Issue
Block a user