[优化] 重写订阅阅读页面,可能有bug喵
This commit is contained in:
6 files changed
+383
-303
No files matched your search
@@ -105,16 +105,13 @@ interface BaseSource : JsExtensions {
|
|||||||
header?.let {
|
header?.let {
|
||||||
try {
|
try {
|
||||||
val json = when {
|
val json = when {
|
||||||
it.startsWith("@js:", true) -> evalJS(it.substring(4))?.toString().orEmpty()
|
it.startsWith("@js:", true) -> evalJS(it.substring(4)).toString()
|
||||||
it.startsWith("<js>", true) -> evalJS(
|
it.startsWith("<js>", true) -> evalJS(
|
||||||
it.substring(4, it.lastIndexOf("<"))
|
it.substring(4, it.lastIndexOf("<"))
|
||||||
)?.toString().orEmpty()
|
).toString()
|
||||||
|
|
||||||
else -> it
|
else -> it
|
||||||
}
|
}
|
||||||
if (json.isBlank()) {
|
|
||||||
return@let
|
|
||||||
}
|
|
||||||
GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
|
GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
|
||||||
putAll(map)
|
putAll(map)
|
||||||
} ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
|
} ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
|
||||||
@@ -122,12 +119,7 @@ interface BaseSource : JsExtensions {
|
|||||||
putAll(map)
|
putAll(map)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val isEmptyJsonNoise = e.message?.contains("Empty JSON string", ignoreCase = true) == true ||
|
AppLog.put("执行请求头规则出错\n$e", e)
|
||||||
e.toString().contains("Empty JSON string", ignoreCase = true) ||
|
|
||||||
e.stackTraceToString().contains("Empty JSON string", ignoreCase = true)
|
|
||||||
if (!isEmptyJsonNoise) {
|
|
||||||
AppLog.put("执行请求头规则出错\n$e", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!has(AppConst.UA_NAME, true)) {
|
if (!has(AppConst.UA_NAME, true)) {
|
||||||
@@ -349,4 +341,4 @@ interface BaseSource : JsExtensions {
|
|||||||
}
|
}
|
||||||
return RhinoScriptEngine.eval(jsStr, scope)
|
return RhinoScriptEngine.eval(jsStr, scope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,7 @@ import io.legado.app.data.appDb
|
|||||||
import io.legado.app.data.entities.RssArticle
|
import io.legado.app.data.entities.RssArticle
|
||||||
import io.legado.app.data.entities.RssSource
|
import io.legado.app.data.entities.RssSource
|
||||||
import io.legado.app.ui.theme.LegadoTheme
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
|
import io.legado.app.ui.widget.components.EmptyMessage
|
||||||
import io.legado.app.ui.widget.components.card.GlassCard
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
|
import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
@@ -295,21 +296,21 @@ private fun LoadMoreFooter(
|
|||||||
state.errorMessage != null -> "加载失败,点击重试"
|
state.errorMessage != null -> "加载失败,点击重试"
|
||||||
else -> "上拉加载更多"
|
else -> "上拉加载更多"
|
||||||
}
|
}
|
||||||
|
val contentModifier = if (state.errorMessage != null) {
|
||||||
|
Modifier.clickable(onClick = onRetry)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 12.dp),
|
.padding(vertical = 12.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
EmptyMessage(
|
||||||
text = text,
|
message = text,
|
||||||
style = LegadoTheme.typography.bodySmall,
|
isLoading = state.isLoadingMore,
|
||||||
color = LegadoTheme.colorScheme.onSurfaceVariant,
|
modifier = contentModifier
|
||||||
modifier = if (state.errorMessage != null) {
|
|
||||||
Modifier.clickable(onClick = onRetry)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
|
|||||||
loadMoreSuccess(it.first)
|
loadMoreSuccess(it.first)
|
||||||
}.onError {
|
}.onError {
|
||||||
AppLog.put("rss获取内容失败", it)
|
AppLog.put("rss获取内容失败", it)
|
||||||
|
if (page > 1) page--
|
||||||
_loadState.value = _loadState.value.copy(
|
_loadState.value = _loadState.value.copy(
|
||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
errorMessage = it.stackTraceStr
|
errorMessage = it.stackTraceStr
|
||||||
@@ -138,7 +139,8 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
|
|||||||
|
|
||||||
_loadState.value = _loadState.value.copy(
|
_loadState.value = _loadState.value.copy(
|
||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
hasMore = !nextPageUrl.isNullOrBlank()
|
hasMore = !nextPageUrl.isNullOrBlank(),
|
||||||
|
errorMessage = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,5 +152,5 @@ data class RssArticlesLoadState(
|
|||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
) {
|
) {
|
||||||
val canLoadMore: Boolean
|
val canLoadMore: Boolean
|
||||||
get() = hasMore && !isRefreshing && !isLoadingMore
|
get() = hasMore && !isRefreshing && !isLoadingMore && errorMessage == null
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -48,12 +49,14 @@ import io.legado.app.ui.rss.read.RedirectPolicy
|
|||||||
import io.legado.app.ui.rss.read.title
|
import io.legado.app.ui.rss.read.title
|
||||||
import io.legado.app.ui.theme.LegadoTheme
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
|
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
|
||||||
|
import io.legado.app.ui.widget.components.AppScaffold
|
||||||
import io.legado.app.ui.widget.components.EmptyMessage
|
import io.legado.app.ui.widget.components.EmptyMessage
|
||||||
import io.legado.app.ui.widget.components.button.SmallIconButton
|
import io.legado.app.ui.widget.components.button.SmallIconButton
|
||||||
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
|
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
|
||||||
import io.legado.app.ui.widget.components.button.TopBarActionButton
|
import io.legado.app.ui.widget.components.button.TopBarActionButton
|
||||||
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
|
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
|
||||||
import io.legado.app.ui.widget.components.card.GlassCard
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
|
import io.legado.app.ui.widget.components.icon.AppIcons
|
||||||
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
|
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
|
||||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
||||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
||||||
@@ -119,172 +122,178 @@ fun RssSortScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
AppScaffold(
|
||||||
GlassMediumFlexibleTopAppBar(
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
title = title,
|
topBar = {
|
||||||
scrollBehavior = scrollBehavior,
|
GlassMediumFlexibleTopAppBar(
|
||||||
navigationIcon = {
|
title = title,
|
||||||
TopBarNavigationButton(onClick = onBackClick, imageVector = Icons.AutoMirrored.Filled.ArrowBack)
|
scrollBehavior = scrollBehavior,
|
||||||
},
|
navigationIcon = {
|
||||||
actions = {
|
TopBarNavigationButton(onClick = onBackClick, imageVector = AppIcons.Back)
|
||||||
TopBarActionButton(
|
},
|
||||||
onClick = { showMainMenu = true },
|
actions = {
|
||||||
imageVector = Icons.Default.MoreVert,
|
TopBarActionButton(
|
||||||
contentDescription = "Menu"
|
onClick = { showMainMenu = true },
|
||||||
)
|
imageVector = AppIcons.MoreVert,
|
||||||
RoundDropdownMenu(
|
contentDescription = "Menu"
|
||||||
expanded = showMainMenu,
|
)
|
||||||
onDismissRequest = { showMainMenu = false }
|
RoundDropdownMenu(
|
||||||
) { dismiss ->
|
expanded = showMainMenu,
|
||||||
if (hasLogin) {
|
onDismissRequest = { showMainMenu = false }
|
||||||
|
) { dismiss ->
|
||||||
|
if (hasLogin) {
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.login),
|
||||||
|
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
onLogin()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
RoundDropdownMenuItem(
|
RoundDropdownMenuItem(
|
||||||
text = stringResource(R.string.login),
|
text = stringResource(R.string.refresh_sort),
|
||||||
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
|
leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
|
||||||
onClick = {
|
onClick = {
|
||||||
dismiss()
|
dismiss()
|
||||||
onLogin()
|
onRefreshSort()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.refresh_sort),
|
|
||||||
leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
onRefreshSort()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.set_source_variable),
|
|
||||||
leadingIcon = { MenuItemIcon(Icons.Default.Dataset) },
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
onSetSourceVariable()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.edit_source),
|
|
||||||
leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
onEditSource()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.switchLayout),
|
|
||||||
leadingIcon = { MenuItemIcon(Icons.Default.Style) },
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
onSwitchLayout()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.read_record),
|
|
||||||
leadingIcon = { MenuItemIcon(Icons.Default.History) },
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
onReadRecord()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.clear),
|
|
||||||
leadingIcon = { MenuItemIcon(Icons.Default.CleaningServices) },
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
onClearArticles()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.redirect_policy),
|
|
||||||
onClick = { showRedirectMenu = true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
RoundDropdownMenu(
|
|
||||||
expanded = showRedirectMenu,
|
|
||||||
onDismissRequest = { showRedirectMenu = false },
|
|
||||||
modifier = Modifier.width(280.dp)
|
|
||||||
) { dismiss ->
|
|
||||||
RedirectPolicy.entries.forEach { policy ->
|
|
||||||
RoundDropdownMenuItem(
|
RoundDropdownMenuItem(
|
||||||
text = policy.title(),
|
text = stringResource(R.string.set_source_variable),
|
||||||
isSelected = policy == redirectPolicy,
|
leadingIcon = { MenuItemIcon(Icons.Default.Dataset) },
|
||||||
onClick = {
|
onClick = {
|
||||||
dismiss()
|
dismiss()
|
||||||
showMainMenu = false
|
onSetSourceVariable()
|
||||||
onRedirectPolicyChanged(policy)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
RoundDropdownMenuItem(
|
||||||
}
|
text = stringResource(R.string.edit_source),
|
||||||
},
|
leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
|
||||||
bottomContent = {
|
onClick = {
|
||||||
if (sortList.size > 1) {
|
dismiss()
|
||||||
Row(
|
onEditSource()
|
||||||
modifier = Modifier
|
}
|
||||||
.fillMaxWidth()
|
|
||||||
.adaptiveHorizontalPaddingTab(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
AppTabRow(
|
|
||||||
tabTitles = tabTitles,
|
|
||||||
selectedTabIndex = selectedTabIndex,
|
|
||||||
onTabSelected = { index ->
|
|
||||||
scope.launch { pagerState.animateScrollToPage(index) }
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.switchLayout),
|
||||||
|
leadingIcon = { MenuItemIcon(Icons.Default.Style) },
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
onSwitchLayout()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.read_record),
|
||||||
|
leadingIcon = { MenuItemIcon(Icons.Default.History) },
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
onReadRecord()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.clear),
|
||||||
|
leadingIcon = { MenuItemIcon(Icons.Default.CleaningServices) },
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
onClearArticles()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.redirect_policy),
|
||||||
|
onClick = { showRedirectMenu = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (BookshelfConfig.shouldShowExpandButton) {
|
RoundDropdownMenu(
|
||||||
Box {
|
expanded = showRedirectMenu,
|
||||||
SmallOutlinedIconToggleButton(
|
onDismissRequest = { showRedirectMenu = false },
|
||||||
checked = showGroupMenu,
|
modifier = Modifier.width(280.dp)
|
||||||
onCheckedChange = { showGroupMenu = it },
|
) { dismiss ->
|
||||||
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
|
RedirectPolicy.entries.forEach { policy ->
|
||||||
contentDescription = stringResource(R.string.group_manage)
|
RoundDropdownMenuItem(
|
||||||
)
|
text = policy.title(),
|
||||||
RoundDropdownMenu(
|
isSelected = policy == redirectPolicy,
|
||||||
expanded = showGroupMenu,
|
onClick = {
|
||||||
onDismissRequest = { showGroupMenu = false }
|
dismiss()
|
||||||
) { dismiss ->
|
showMainMenu = false
|
||||||
sortList.forEachIndexed { index, group ->
|
onRedirectPolicyChanged(policy)
|
||||||
RoundDropdownMenuItem(
|
}
|
||||||
text = group.first,
|
)
|
||||||
onClick = {
|
}
|
||||||
scope.launch { pagerState.animateScrollToPage(index) }
|
}
|
||||||
dismiss()
|
},
|
||||||
},
|
bottomContent = {
|
||||||
trailingIcon = {
|
if (sortList.size > 1) {
|
||||||
if (selectedTabIndex == index) {
|
Row(
|
||||||
Icon(
|
modifier = Modifier
|
||||||
Icons.Default.Check,
|
.fillMaxWidth()
|
||||||
null,
|
.adaptiveHorizontalPaddingTab(),
|
||||||
modifier = Modifier.size(18.dp)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
)
|
) {
|
||||||
|
AppTabRow(
|
||||||
|
tabTitles = tabTitles,
|
||||||
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
onTabSelected = { index ->
|
||||||
|
scope.launch { pagerState.animateScrollToPage(index) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (BookshelfConfig.shouldShowExpandButton) {
|
||||||
|
Box {
|
||||||
|
SmallOutlinedIconToggleButton(
|
||||||
|
checked = showGroupMenu,
|
||||||
|
onCheckedChange = { showGroupMenu = it },
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
|
||||||
|
contentDescription = stringResource(R.string.group_manage)
|
||||||
|
)
|
||||||
|
RoundDropdownMenu(
|
||||||
|
expanded = showGroupMenu,
|
||||||
|
onDismissRequest = { showGroupMenu = false }
|
||||||
|
) { dismiss ->
|
||||||
|
sortList.forEachIndexed { index, group ->
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = group.first,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(index) }
|
||||||
|
dismiss()
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (selectedTabIndex == index) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
|
) { paddingValues ->
|
||||||
if (sortList.isEmpty()) {
|
if (sortList.isEmpty()) {
|
||||||
EmptyMessage(
|
EmptyMessage(
|
||||||
message = stringResource(R.string.empty),
|
message = stringResource(R.string.empty),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
.padding(24.dp)
|
.padding(24.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
contentPadding = PaddingValues(bottom = 0.dp),
|
contentPadding = PaddingValues(bottom = 0.dp),
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
) { page ->
|
) { page ->
|
||||||
val item = sortList.getOrNull(page) ?: return@HorizontalPager
|
val item = sortList.getOrNull(page) ?: return@HorizontalPager
|
||||||
pagerContent(page, item)
|
pagerContent(page, item)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.annotation.SuppressLint
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.net.http.SslError
|
import android.net.http.SslError
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.SslErrorHandler
|
import android.webkit.SslErrorHandler
|
||||||
@@ -18,11 +19,18 @@ import android.webkit.WebViewClient
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.LocalActivity
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Login
|
import androidx.compose.material.icons.automirrored.filled.Login
|
||||||
@@ -46,9 +54,11 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -67,16 +77,18 @@ import io.legado.app.ui.config.otherConfig.OtherConfig
|
|||||||
import io.legado.app.ui.login.SourceLoginActivity
|
import io.legado.app.ui.login.SourceLoginActivity
|
||||||
import io.legado.app.ui.theme.LegadoTheme
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
import io.legado.app.ui.widget.components.AppTextField
|
import io.legado.app.ui.widget.components.AppTextField
|
||||||
import io.legado.app.ui.widget.components.button.SmallIconButton
|
import io.legado.app.ui.widget.components.button.MediumIconButton
|
||||||
import io.legado.app.ui.widget.components.button.TopBarActionButton
|
|
||||||
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
|
|
||||||
import io.legado.app.ui.widget.components.card.GlassCard
|
import io.legado.app.ui.widget.components.card.GlassCard
|
||||||
|
import io.legado.app.ui.widget.components.card.NormalCard
|
||||||
|
import io.legado.app.ui.widget.components.button.SmallIconButton
|
||||||
|
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
|
||||||
|
import io.legado.app.ui.widget.components.button.TopBarActionButton
|
||||||
|
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
|
||||||
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
|
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
|
||||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
||||||
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
||||||
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
||||||
import io.legado.app.ui.widget.components.text.AppText
|
import io.legado.app.ui.widget.components.text.AppText
|
||||||
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
|
|
||||||
import io.legado.app.utils.NetworkUtils
|
import io.legado.app.utils.NetworkUtils
|
||||||
import io.legado.app.utils.isTrue
|
import io.legado.app.utils.isTrue
|
||||||
import io.legado.app.utils.keepScreenOn
|
import io.legado.app.utils.keepScreenOn
|
||||||
@@ -86,6 +98,8 @@ import io.legado.app.utils.setDarkeningAllowed
|
|||||||
import io.legado.app.utils.share
|
import io.legado.app.utils.share
|
||||||
import io.legado.app.utils.startActivity
|
import io.legado.app.utils.startActivity
|
||||||
import io.legado.app.utils.toastOnUi
|
import io.legado.app.utils.toastOnUi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.apache.commons.text.StringEscapeUtils
|
import org.apache.commons.text.StringEscapeUtils
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
@@ -115,6 +129,8 @@ fun RssReadRouteScreen(
|
|||||||
var webProgress by remember { mutableIntStateOf(100) }
|
var webProgress by remember { mutableIntStateOf(100) }
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
var showRedirectMenu by remember { mutableStateOf(false) }
|
var showRedirectMenu by remember { mutableStateOf(false) }
|
||||||
|
var showFloatingCard by remember { mutableStateOf(true) }
|
||||||
|
var showFloatingCardJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
|
||||||
@@ -174,157 +190,208 @@ fun RssReadRouteScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
GlassMediumFlexibleTopAppBar(
|
VisibleWebViewCompose(
|
||||||
title = pageTitle.ifBlank { defaultTopBarTitle },
|
modifier = Modifier.fillMaxSize(),
|
||||||
navigationIcon = {
|
onCreated = { createdWebView ->
|
||||||
TopBarNavigationButton(
|
webView = createdWebView
|
||||||
onClick = onBackClick,
|
configureRssReadWebView(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack
|
webView = createdWebView,
|
||||||
)
|
context = context,
|
||||||
},
|
activity = activity,
|
||||||
scrollBehavior = null,
|
appCompatActivity = appCompatActivity,
|
||||||
actions = {
|
viewModel = viewModel,
|
||||||
TopBarActionButton(
|
initialTitle = title,
|
||||||
onClick = { viewModel.refresh { webView?.reload() } },
|
redirectPolicyProvider = { redirectPolicy },
|
||||||
imageVector = Icons.Default.Refresh,
|
callbacks = RssReadWebControllerCallbacks(
|
||||||
contentDescription = stringResource(R.string.refresh)
|
onProgressChanged = { webProgress = it },
|
||||||
)
|
onPageTitleResolved = { resolved ->
|
||||||
TopBarActionButton(
|
pageTitle = resolved.ifBlank { defaultTopBarTitle }
|
||||||
onClick = {
|
|
||||||
viewModel.addFavorite()
|
|
||||||
favoriteTitle = viewModel.rssArticle?.title.orEmpty()
|
|
||||||
favoriteGroup = viewModel.rssArticle?.group.orEmpty()
|
|
||||||
showFavoriteSheet = true
|
|
||||||
},
|
|
||||||
imageVector = Icons.Default.Star,
|
|
||||||
contentDescription = stringResource(R.string.favorite)
|
|
||||||
)
|
|
||||||
TopBarActionButton(
|
|
||||||
onClick = { showMenu = true },
|
|
||||||
imageVector = Icons.Default.MoreVert,
|
|
||||||
contentDescription = "Menu"
|
|
||||||
)
|
|
||||||
RoundDropdownMenu(
|
|
||||||
expanded = showMenu,
|
|
||||||
onDismissRequest = { showMenu = false }
|
|
||||||
) { dismiss ->
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.share),
|
|
||||||
leadingIcon = { MenuItemIcon(Icons.Default.Share) },
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
webView?.url?.let {
|
|
||||||
context.share(it)
|
|
||||||
} ?: viewModel.rssArticle?.let {
|
|
||||||
context.share(it.link)
|
|
||||||
} ?: context.toastOnUi(R.string.null_url)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
RoundDropdownMenuItem(
|
)
|
||||||
text = if (isSpeaking) stringResource(R.string.aloud_stop) else stringResource(
|
createdWebView.setOnTouchListener { _, event ->
|
||||||
R.string.read_aloud
|
when (event.actionMasked) {
|
||||||
),
|
MotionEvent.ACTION_DOWN,
|
||||||
leadingIcon = {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
MenuItemIcon(if (isSpeaking) Icons.Default.Stop else Icons.Default.VolumeUp)
|
showFloatingCardJob?.cancel()
|
||||||
},
|
showFloatingCard = false
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
if (isSpeaking) {
|
|
||||||
viewModel.stopReadAloud()
|
|
||||||
} else {
|
|
||||||
webView?.evaluateJavascript("document.documentElement.outerHTML") {
|
|
||||||
val html = StringEscapeUtils.unescapeJson(it)
|
|
||||||
.replace("^\"|\"$".toRegex(), "")
|
|
||||||
viewModel.readAloud(
|
|
||||||
Jsoup.parse(html)
|
|
||||||
.text()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
if (!viewModel.rssSource?.loginUrl.isNullOrBlank()) {
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.login),
|
|
||||||
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
context.startActivity<SourceLoginActivity> {
|
|
||||||
putExtra("type", "rssSource")
|
|
||||||
putExtra("key", viewModel.rssSource?.sourceUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.redirect_policy),
|
|
||||||
onClick = { showRedirectMenu = true }
|
|
||||||
)
|
|
||||||
RoundDropdownMenuItem(
|
|
||||||
text = stringResource(R.string.open_in_browser),
|
|
||||||
leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) },
|
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
webView?.url?.let { context.openUrl(it) } ?: context.toastOnUi("url null")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
RoundDropdownMenu(
|
MotionEvent.ACTION_UP,
|
||||||
expanded = showRedirectMenu,
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
onDismissRequest = { showRedirectMenu = false }
|
createdWebView.performClick()
|
||||||
) { dismiss ->
|
showFloatingCardJob?.cancel()
|
||||||
RedirectPolicy.entries.forEach { policy ->
|
showFloatingCardJob = scope.launch {
|
||||||
RoundDropdownMenuItem(
|
delay(180L)
|
||||||
text = policy.title(),
|
showFloatingCard = true
|
||||||
onClick = {
|
|
||||||
dismiss()
|
|
||||||
showMenu = false
|
|
||||||
viewModel.rssSource?.let { source ->
|
|
||||||
viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
|
|
||||||
redirectPolicy = policy
|
|
||||||
}
|
|
||||||
context.toastOnUi("重定向策略已更新")
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
if (policy == redirectPolicy) {
|
|
||||||
Icon(Icons.Default.Check, null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if (webProgress in 0..99) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
LinearProgressIndicator(
|
||||||
VisibleWebViewCompose(
|
progress = { webProgress / 100f },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxWidth()
|
||||||
onCreated = { createdWebView ->
|
|
||||||
webView = createdWebView
|
|
||||||
configureRssReadWebView(
|
|
||||||
webView = createdWebView,
|
|
||||||
context = context,
|
|
||||||
activity = activity,
|
|
||||||
appCompatActivity = appCompatActivity,
|
|
||||||
viewModel = viewModel,
|
|
||||||
initialTitle = title,
|
|
||||||
redirectPolicyProvider = { redirectPolicy },
|
|
||||||
callbacks = RssReadWebControllerCallbacks(
|
|
||||||
onProgressChanged = { webProgress = it },
|
|
||||||
onPageTitleResolved = { resolved ->
|
|
||||||
pageTitle = resolved.ifBlank { defaultTopBarTitle }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
if (webProgress in 0..99) {
|
}
|
||||||
LinearProgressIndicator(
|
TopFloatingStickyItem(
|
||||||
progress = { webProgress / 100f },
|
item = if (showFloatingCard) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
RssReadFloatingSummary(
|
||||||
|
titleText = pageTitle.ifBlank { defaultTopBarTitle },
|
||||||
|
progressText = if (webProgress in 0..99) " · ${webProgress}%" else null
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(top = 6.dp),
|
||||||
|
) { summary ->
|
||||||
|
NormalCard(
|
||||||
|
modifier = Modifier.animateContentSize(),
|
||||||
|
cornerRadius = 32.dp,
|
||||||
|
containerColor = LegadoTheme.colorScheme.surfaceContainer,
|
||||||
|
contentColor = LegadoTheme.colorScheme.onCardContainer
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
SmallTonalIconButton(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.back),
|
||||||
|
onClick = onBackClick
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
AppText(
|
||||||
|
text = summary.titleText,
|
||||||
|
style = LegadoTheme.typography.labelSmallEmphasized,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
summary.progressText?.let { text ->
|
||||||
|
AppText(text = text, style = LegadoTheme.typography.labelMediumEmphasized)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
SmallTonalIconButton(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = stringResource(R.string.refresh),
|
||||||
|
onClick = { viewModel.refresh { webView?.reload() } }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
SmallTonalIconButton(
|
||||||
|
imageVector = Icons.Default.Star,
|
||||||
|
contentDescription = stringResource(R.string.favorite),
|
||||||
|
onClick = {
|
||||||
|
viewModel.addFavorite()
|
||||||
|
favoriteTitle = viewModel.rssArticle?.title.orEmpty()
|
||||||
|
favoriteGroup = viewModel.rssArticle?.group.orEmpty()
|
||||||
|
showFavoriteSheet = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box {
|
||||||
|
SmallTonalIconButton(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = "Menu",
|
||||||
|
onClick = { showMenu = true }
|
||||||
|
)
|
||||||
|
RoundDropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false }
|
||||||
|
) { dismiss ->
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.share),
|
||||||
|
leadingIcon = { MenuItemIcon(Icons.Default.Share) },
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
webView?.url?.let {
|
||||||
|
context.share(it)
|
||||||
|
} ?: viewModel.rssArticle?.let {
|
||||||
|
context.share(it.link)
|
||||||
|
} ?: context.toastOnUi(R.string.null_url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = if (isSpeaking) stringResource(R.string.aloud_stop) else stringResource(
|
||||||
|
R.string.read_aloud
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
MenuItemIcon(if (isSpeaking) Icons.Default.Stop else Icons.Default.VolumeUp)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
if (isSpeaking) {
|
||||||
|
viewModel.stopReadAloud()
|
||||||
|
} else {
|
||||||
|
webView?.evaluateJavascript("document.documentElement.outerHTML") {
|
||||||
|
val html = StringEscapeUtils.unescapeJson(it)
|
||||||
|
.replace("^\"|\"$".toRegex(), "")
|
||||||
|
viewModel.readAloud(
|
||||||
|
Jsoup.parse(html).text()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!viewModel.rssSource?.loginUrl.isNullOrBlank()) {
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.login),
|
||||||
|
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
context.startActivity<SourceLoginActivity> {
|
||||||
|
putExtra("type", "rssSource")
|
||||||
|
putExtra("key", viewModel.rssSource?.sourceUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.redirect_policy),
|
||||||
|
onClick = { showRedirectMenu = true }
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.open_in_browser),
|
||||||
|
leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) },
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
webView?.url?.let { context.openUrl(it) } ?: context.toastOnUi("url null")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RoundDropdownMenu(
|
||||||
|
expanded = showRedirectMenu,
|
||||||
|
onDismissRequest = { showRedirectMenu = false }
|
||||||
|
) { dismiss ->
|
||||||
|
RedirectPolicy.entries.forEach { policy ->
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = policy.title(),
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
showMenu = false
|
||||||
|
viewModel.rssSource?.let { source ->
|
||||||
|
viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
|
||||||
|
redirectPolicy = policy
|
||||||
|
}
|
||||||
|
context.toastOnUi("重定向策略已更新")
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (policy == redirectPolicy) {
|
||||||
|
Icon(Icons.Default.Check, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,6 +414,11 @@ fun RssReadRouteScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class RssReadFloatingSummary(
|
||||||
|
val titleText: String,
|
||||||
|
val progressText: String?,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FavoriteEditSheet(
|
private fun FavoriteEditSheet(
|
||||||
show: Boolean,
|
show: Boolean,
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class VisibleWebView(
|
|||||||
super.onWindowVisibilityChanged(VISIBLE)
|
super.onWindowVisibilityChanged(VISIBLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun performClick(): Boolean {
|
||||||
|
return super.performClick()
|
||||||
|
}
|
||||||
|
|
||||||
override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
|
override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
|
||||||
return super.startActionMode(createWrappedCallback(callback))
|
return super.startActionMode(createWrappedCallback(callback))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user