Merge pull request #1750 from omnivore-app/feature/android-read-now-button
Read Now - Android Share Activity
This commit is contained in:
@ -51,5 +51,11 @@
|
||||
android:name=".ui.reader.PDFReaderActivity"
|
||||
android:theme="@style/Theme.AppCompat.NoActionBar"
|
||||
android:windowSoftInputMode="adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.reader.WebReaderLoadingContainerActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Omnivore"/>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@ -8,11 +8,12 @@ import app.omnivore.omnivore.persistence.entities.Highlight
|
||||
data class SavedItemQueryResponse(
|
||||
val item: SavedItem?,
|
||||
val highlights: List<Highlight>,
|
||||
val labels: List<SavedItemLabel>
|
||||
val labels: List<SavedItemLabel>,
|
||||
val state: String
|
||||
) {
|
||||
companion object {
|
||||
fun emptyResponse(): SavedItemQueryResponse {
|
||||
return SavedItemQueryResponse(null, listOf(), listOf())
|
||||
return SavedItemQueryResponse(null, listOf(), listOf(), state = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -79,8 +80,8 @@ suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse {
|
||||
content = article.articleFields.content
|
||||
)
|
||||
|
||||
return SavedItemQueryResponse(item = savedItem, highlights, labels = savedItemLabels)
|
||||
return SavedItemQueryResponse(item = savedItem, highlights, labels = savedItemLabels, state = article.articleFields.state?.rawValue ?: "")
|
||||
} catch (e: java.lang.Exception) {
|
||||
return SavedItemQueryResponse(item = null, listOf(), labels = listOf())
|
||||
return SavedItemQueryResponse(item = null, listOf(), labels = listOf(), state = "")
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,123 +41,6 @@ import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@Composable
|
||||
fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) {
|
||||
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
|
||||
var isMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showWebPreferencesDialog by remember { mutableStateOf(false ) }
|
||||
|
||||
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null)
|
||||
val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null)
|
||||
val shouldPopView: Boolean by webReaderViewModel.shouldPopViewLiveData.observeAsState(false)
|
||||
|
||||
val maxToolbarHeight = 48.dp
|
||||
val maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() }
|
||||
val toolbarHeightPx = remember { mutableStateOf(maxToolbarHeightPx) }
|
||||
|
||||
// Create a connection to the nested scroll system and listen to the scroll happening inside child Column
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.y
|
||||
val newHeight = toolbarHeightPx.value + delta
|
||||
toolbarHeightPx.value = newHeight.coerceIn(0f, maxToolbarHeightPx)
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (webReaderParams == null) {
|
||||
webReaderViewModel.loadItem(slug = slug)
|
||||
}
|
||||
|
||||
if (webReaderParams != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(webReaderViewModel.scrollState)
|
||||
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.requiredHeight(height = maxToolbarHeight)
|
||||
) {
|
||||
}
|
||||
WebReader(webReaderParams!!, webReaderViewModel.storedWebPreferences(isSystemInDarkTheme()), webReaderViewModel)
|
||||
}
|
||||
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(height = with(LocalDensity.current) {
|
||||
webReaderViewModel.currentToolbarHeight = toolbarHeightPx.value.toInt()
|
||||
toolbarHeightPx.value.roundToInt().toDp()
|
||||
} ),
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
title = {},
|
||||
actions = {
|
||||
// Disabling menu until we implement local persistence
|
||||
IconButton(onClick = { isMenuExpanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showWebPreferencesDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings, // TODO: set a better icon
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
SavedItemContextMenu(
|
||||
isExpanded = isMenuExpanded,
|
||||
isArchived = webReaderParams!!.item.isArchived,
|
||||
onDismiss = { isMenuExpanded = false },
|
||||
actionHandler = { webReaderViewModel.handleSavedItemAction(webReaderParams!!.item.savedItemId, it) }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (showWebPreferencesDialog) {
|
||||
WebPreferencesDialog(
|
||||
onDismiss = {
|
||||
showWebPreferencesDialog = false
|
||||
},
|
||||
webReaderViewModel = webReaderViewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (annotation != null) {
|
||||
AnnotationEditView(
|
||||
initialAnnotation = annotation!!,
|
||||
onSave = {
|
||||
webReaderViewModel.saveAnnotation(it)
|
||||
},
|
||||
onCancel = {
|
||||
webReaderViewModel.cancelAnnotationEdit()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldPopView) {
|
||||
if (shouldPopView) {
|
||||
onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: add a proper loading view
|
||||
Text("Loading...")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun WebReader(
|
||||
|
||||
@ -0,0 +1,232 @@
|
||||
package app.omnivore.omnivore.ui.reader
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import app.omnivore.omnivore.MainActivity
|
||||
import app.omnivore.omnivore.ui.savedItemViews.SavedItemContextMenu
|
||||
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@AndroidEntryPoint
|
||||
class WebReaderLoadingContainerActivity: ComponentActivity() {
|
||||
val viewModel: WebReaderViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val requestID = intent.getStringExtra("SAVED_ITEM_REQUEST_ID") ?: ""
|
||||
|
||||
setContent {
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val useDarkIcons = !isSystemInDarkTheme()
|
||||
|
||||
OmnivoreTheme {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color.Black)
|
||||
.systemBarsPadding()
|
||||
) {
|
||||
if (viewModel.hasFetchError.value == true) {
|
||||
Text("We were unable to fetch your content.")
|
||||
} else {
|
||||
WebReaderLoadingContainer(
|
||||
requestID = requestID,
|
||||
onLibraryIconTap = { startMainActivity() },
|
||||
webReaderViewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(systemUiController, useDarkIcons) {
|
||||
systemUiController.setSystemBarsColor(
|
||||
color = Color.Black,
|
||||
darkIcons = false
|
||||
)
|
||||
|
||||
onDispose {}
|
||||
}
|
||||
}
|
||||
|
||||
// animate the view up when keyboard appears
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val rootView = findViewById<View>(android.R.id.content).rootView
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
|
||||
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||
rootView.setPadding(0, 0, 0, imeHeight)
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMainActivity() {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
this.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, onLibraryIconTap: (() -> Unit)? = null, webReaderViewModel: WebReaderViewModel) {
|
||||
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
|
||||
var isMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showWebPreferencesDialog by remember { mutableStateOf(false ) }
|
||||
|
||||
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null)
|
||||
val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null)
|
||||
val shouldPopView: Boolean by webReaderViewModel.shouldPopViewLiveData.observeAsState(false)
|
||||
|
||||
val maxToolbarHeight = 48.dp
|
||||
val maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() }
|
||||
val toolbarHeightPx = remember { mutableStateOf(maxToolbarHeightPx) }
|
||||
|
||||
// Create a connection to the nested scroll system and listen to the scroll happening inside child Column
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.y
|
||||
val newHeight = toolbarHeightPx.value + delta
|
||||
toolbarHeightPx.value = newHeight.coerceIn(0f, maxToolbarHeightPx)
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (webReaderParams == null) {
|
||||
webReaderViewModel.loadItem(slug = slug, requestID = requestID)
|
||||
}
|
||||
|
||||
if (webReaderParams != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(webReaderViewModel.scrollState)
|
||||
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.requiredHeight(height = maxToolbarHeight)
|
||||
) {
|
||||
}
|
||||
WebReader(webReaderParams!!, webReaderViewModel.storedWebPreferences(isSystemInDarkTheme()), webReaderViewModel)
|
||||
}
|
||||
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.height(height = with(LocalDensity.current) {
|
||||
webReaderViewModel.currentToolbarHeight = toolbarHeightPx.value.toInt()
|
||||
toolbarHeightPx.value.roundToInt().toDp()
|
||||
} ),
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
title = {},
|
||||
actions = {
|
||||
if (onLibraryIconTap != null) {
|
||||
IconButton(onClick = { onLibraryIconTap() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Home,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { isMenuExpanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showWebPreferencesDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings, // TODO: set a better icon
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
SavedItemContextMenu(
|
||||
isExpanded = isMenuExpanded,
|
||||
isArchived = webReaderParams!!.item.isArchived,
|
||||
onDismiss = { isMenuExpanded = false },
|
||||
actionHandler = { webReaderViewModel.handleSavedItemAction(webReaderParams!!.item.savedItemId, it) }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (showWebPreferencesDialog) {
|
||||
WebPreferencesDialog(
|
||||
onDismiss = {
|
||||
showWebPreferencesDialog = false
|
||||
},
|
||||
webReaderViewModel = webReaderViewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (annotation != null) {
|
||||
AnnotationEditView(
|
||||
initialAnnotation = annotation!!,
|
||||
onSave = {
|
||||
webReaderViewModel.saveAnnotation(it)
|
||||
},
|
||||
onCancel = {
|
||||
webReaderViewModel.cancelAnnotationEdit()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldPopView) {
|
||||
if (shouldPopView) {
|
||||
onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text("Loading...", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -41,20 +41,41 @@ class WebReaderViewModel @Inject constructor(
|
||||
val annotationLiveData = MutableLiveData<String?>(null)
|
||||
val javascriptActionLoopUUIDLiveData = MutableLiveData(lastJavascriptActionLoopUUID)
|
||||
val shouldPopViewLiveData = MutableLiveData<Boolean>(false)
|
||||
val hasFetchError = MutableLiveData<Boolean>(false)
|
||||
|
||||
var hasTappedExistingHighlight = false
|
||||
var lastTapCoordinates: TapCoordinates? = null
|
||||
|
||||
fun loadItem(slug: String) {
|
||||
fun loadItem(slug: String?, requestID: String?) {
|
||||
viewModelScope.launch {
|
||||
val webReaderParams = loadItemFromServer(slug)
|
||||
slug?.let { loadItemUsingSlug(it) }
|
||||
requestID?.let { loadItemUsingRequestID(it) }
|
||||
}
|
||||
}
|
||||
|
||||
if (webReaderParams != null) {
|
||||
Log.d("sync", "data loaded from server")
|
||||
webReaderParamsLiveData.postValue(webReaderParams)
|
||||
} else {
|
||||
loadItemFromDB(slug)
|
||||
}
|
||||
private suspend fun loadItemUsingSlug(slug: String) {
|
||||
val webReaderParams = loadItemFromServer(slug)
|
||||
|
||||
if (webReaderParams != null) {
|
||||
Log.d("sync", "data loaded from server")
|
||||
webReaderParamsLiveData.postValue(webReaderParams)
|
||||
} else {
|
||||
loadItemFromDB(slug)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadItemUsingRequestID(requestID: String, requestCount: Int = 0) {
|
||||
val webReaderParams = loadItemFromServer(requestID)
|
||||
val isSuccessful = webReaderParams?.articleContent?.contentStatus == "SUCCEEDED"
|
||||
|
||||
if (webReaderParams != null && isSuccessful) {
|
||||
webReaderParamsLiveData.postValue(webReaderParams)
|
||||
} else if (requestCount < 7) {
|
||||
// delay then try again
|
||||
delay(2000L)
|
||||
loadItemUsingRequestID(requestID = requestID, requestCount = requestCount + 1)
|
||||
} else {
|
||||
hasFetchError.postValue(true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,7 +108,7 @@ class WebReaderViewModel @Inject constructor(
|
||||
title = article.title,
|
||||
htmlContent = article.content ?: "",
|
||||
highlights = articleQueryResult.highlights,
|
||||
contentStatus = "SUCCEEDED",
|
||||
contentStatus = articleQueryResult.state,
|
||||
objectID = "",
|
||||
labelsJSONString = Gson().toJson(articleQueryResult.labels)
|
||||
)
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package app.omnivore.omnivore.ui.save
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@ -11,13 +13,18 @@ import androidx.compose.material.*
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.omnivore.omnivore.MainActivity
|
||||
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
|
||||
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomSheetState, modifier: Modifier) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column(
|
||||
@ -29,17 +36,41 @@ fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomShee
|
||||
.padding(top = 48.dp, bottom = 32.dp)
|
||||
) {
|
||||
Text(text = viewModel.message ?: "Saving")
|
||||
Button(onClick = {
|
||||
coroutineScope.launch {
|
||||
modalBottomSheetState.hide()
|
||||
Row {
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
modalBottomSheetState.hide()
|
||||
viewModel.clientRequestID?.let {
|
||||
val intent = Intent(context, WebReaderLoadingContainerActivity::class.java)
|
||||
intent.putExtra("SAVED_ITEM_REQUEST_ID", it)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
contentColor = Color(0xFF3D3D3D),
|
||||
backgroundColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = "Read Now")
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
modalBottomSheetState.hide()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
contentColor = Color(0xFF3D3D3D),
|
||||
backgroundColor = Color(0xffffd234)
|
||||
)
|
||||
) {
|
||||
Text(text = "Dismiss")
|
||||
)
|
||||
) {
|
||||
Text(text = "Read Later")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,9 @@ class SaveViewModel @Inject constructor(
|
||||
var message by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
var clientRequestID by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
private fun getAuthToken(): String? = runBlocking {
|
||||
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
|
||||
}
|
||||
@ -52,10 +55,12 @@ class SaveViewModel @Inject constructor(
|
||||
.build()
|
||||
|
||||
try {
|
||||
clientRequestID = UUID.randomUUID().toString()
|
||||
|
||||
val response = apolloClient.mutation(
|
||||
SaveUrlMutation(
|
||||
SaveUrlInput(
|
||||
clientRequestId = UUID.randomUUID().toString(),
|
||||
clientRequestId = clientRequestID!!,
|
||||
source = "android",
|
||||
url = url
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user