Add space alias in creation wizard
This commit is contained in:
parent
5325c761f4
commit
fb337dacd6
|
@ -16,6 +16,8 @@
|
|||
|
||||
package org.matrix.android.sdk.api.session.room
|
||||
|
||||
import arrow.core.Either
|
||||
import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure
|
||||
import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility
|
||||
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams
|
||||
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse
|
||||
|
@ -40,4 +42,6 @@ interface RoomDirectoryService {
|
|||
* Set the visibility of a room in the directory
|
||||
*/
|
||||
suspend fun setRoomDirectoryVisibility(roomId: String, roomDirectoryVisibility: RoomDirectoryVisibility)
|
||||
|
||||
suspend fun checkAliasAvailability(aliasLocalPart: String?) : Result<Unit>
|
||||
}
|
||||
|
|
|
@ -36,7 +36,11 @@ interface SpaceService {
|
|||
/**
|
||||
* Just a shortcut for space creation for ease of use
|
||||
*/
|
||||
suspend fun createSpace(name: String, topic: String?, avatarUri: Uri?, isPublic: Boolean): String
|
||||
suspend fun createSpace(name: String,
|
||||
topic: String?,
|
||||
avatarUri: Uri?,
|
||||
isPublic: Boolean,
|
||||
roomAliasLocalPart: String? = null): String
|
||||
|
||||
/**
|
||||
* Get a space from a roomId
|
||||
|
|
|
@ -16,10 +16,15 @@
|
|||
|
||||
package org.matrix.android.sdk.internal.session.room
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.Right
|
||||
import org.matrix.android.sdk.api.session.room.RoomDirectoryService
|
||||
import org.matrix.android.sdk.api.session.room.alias.RoomAliasError
|
||||
import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure
|
||||
import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility
|
||||
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams
|
||||
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse
|
||||
import org.matrix.android.sdk.internal.session.room.alias.RoomAliasAvailabilityChecker
|
||||
import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask
|
||||
import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask
|
||||
import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask
|
||||
|
@ -28,7 +33,8 @@ import javax.inject.Inject
|
|||
internal class DefaultRoomDirectoryService @Inject constructor(
|
||||
private val getPublicRoomTask: GetPublicRoomTask,
|
||||
private val getRoomDirectoryVisibilityTask: GetRoomDirectoryVisibilityTask,
|
||||
private val setRoomDirectoryVisibilityTask: SetRoomDirectoryVisibilityTask
|
||||
private val setRoomDirectoryVisibilityTask: SetRoomDirectoryVisibilityTask,
|
||||
private val roomAliasAvailabilityChecker: RoomAliasAvailabilityChecker
|
||||
) : RoomDirectoryService {
|
||||
|
||||
override suspend fun getPublicRooms(server: String?,
|
||||
|
@ -43,4 +49,14 @@ internal class DefaultRoomDirectoryService @Inject constructor(
|
|||
override suspend fun setRoomDirectoryVisibility(roomId: String, roomDirectoryVisibility: RoomDirectoryVisibility) {
|
||||
setRoomDirectoryVisibilityTask.execute(SetRoomDirectoryVisibilityTask.Params(roomId, roomDirectoryVisibility))
|
||||
}
|
||||
|
||||
|
||||
override suspend fun checkAliasAvailability(aliasLocalPart: String?): Result<Unit> {
|
||||
return try {
|
||||
roomAliasAvailabilityChecker.check(aliasLocalPart)
|
||||
Result.success(Unit)
|
||||
} catch (failure: RoomAliasError) {
|
||||
Result.failure(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,13 +51,13 @@ internal class RoomAliasAvailabilityChecker @Inject constructor(
|
|||
} catch (throwable: Throwable) {
|
||||
if (throwable is Failure.ServerError && throwable.httpCode == 404) {
|
||||
// This is a 404, so the alias is available: nominal case
|
||||
null
|
||||
return
|
||||
} else {
|
||||
// Other error, propagate it
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
?.let {
|
||||
.let {
|
||||
// Alias already exists: error case
|
||||
throw RoomAliasError.AliasNotAvailable
|
||||
}
|
||||
|
|
|
@ -66,12 +66,13 @@ internal class DefaultSpaceService @Inject constructor(
|
|||
return createRoomTask.executeRetry(params, 3)
|
||||
}
|
||||
|
||||
override suspend fun createSpace(name: String, topic: String?, avatarUri: Uri?, isPublic: Boolean): String {
|
||||
override suspend fun createSpace(name: String, topic: String?, avatarUri: Uri?, isPublic: Boolean, roomAliasLocalPart: String?): String {
|
||||
return createSpace(CreateSpaceParams().apply {
|
||||
this.name = name
|
||||
this.topic = topic
|
||||
this.avatarUri = avatarUri
|
||||
if (isPublic) {
|
||||
this.roomAliasName = roomAliasLocalPart
|
||||
this.powerLevelContentOverride = (powerLevelContentOverride ?: PowerLevelsContent()).copy(
|
||||
invite = 0
|
||||
)
|
||||
|
|
|
@ -40,6 +40,9 @@ abstract class FormEditTextItem : VectorEpoxyModel<FormEditTextItem.Holder>() {
|
|||
@EpoxyAttribute
|
||||
var value: String? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var forceUpdateValue: Boolean = false
|
||||
|
||||
@EpoxyAttribute
|
||||
var errorMessage: String? = null
|
||||
|
||||
|
@ -64,12 +67,23 @@ abstract class FormEditTextItem : VectorEpoxyModel<FormEditTextItem.Holder>() {
|
|||
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
|
||||
var editorActionListener: TextView.OnEditorActionListener? = null
|
||||
|
||||
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
|
||||
var onFocusChange: ((Boolean) -> Unit)? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var inputPrefix: String? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var inputSuffix: String? = null
|
||||
|
||||
private val onTextChangeListener = object : SimpleTextWatcher() {
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
onTextChange?.invoke(s.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private val onFocusChangedListener = View.OnFocusChangeListener { _, hasFocus -> onFocusChange?.invoke(hasFocus) }
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
holder.textInputLayout.isEnabled = enabled
|
||||
|
@ -77,7 +91,14 @@ abstract class FormEditTextItem : VectorEpoxyModel<FormEditTextItem.Holder>() {
|
|||
holder.textInputLayout.error = errorMessage
|
||||
holder.textInputLayout.endIconMode = endIconMode ?: TextInputLayout.END_ICON_NONE
|
||||
|
||||
holder.setValueOnce(holder.textInputEditText, value)
|
||||
holder.textInputLayout.prefixText = inputPrefix
|
||||
holder.textInputLayout.suffixText = inputSuffix
|
||||
|
||||
if (forceUpdateValue) {
|
||||
holder.textInputEditText.setText(value)
|
||||
} else {
|
||||
holder.setValueOnce(holder.textInputEditText, value)
|
||||
}
|
||||
|
||||
holder.textInputEditText.isEnabled = enabled
|
||||
inputType?.let { holder.textInputEditText.inputType = it }
|
||||
|
@ -86,6 +107,7 @@ abstract class FormEditTextItem : VectorEpoxyModel<FormEditTextItem.Holder>() {
|
|||
|
||||
holder.textInputEditText.addTextChangedListenerOnce(onTextChangeListener)
|
||||
holder.textInputEditText.setOnEditorActionListener(editorActionListener)
|
||||
holder.textInputEditText.onFocusChangeListener = onFocusChangedListener
|
||||
}
|
||||
|
||||
override fun shouldSaveViewState(): Boolean {
|
||||
|
|
|
@ -113,6 +113,9 @@ class SpaceCreationActivity : SimpleFragmentActivity(), CreateSpaceViewModel.Fac
|
|||
CreateSpaceEvents.HideModalLoading -> {
|
||||
hideWaitingView()
|
||||
}
|
||||
is CreateSpaceEvents.ShowModalLoading -> {
|
||||
showWaitingView(it.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ sealed class CreateSpaceAction : VectorViewModelAction {
|
|||
data class SetRoomType(val type: SpaceType) : CreateSpaceAction()
|
||||
data class NameChanged(val name: String) : CreateSpaceAction()
|
||||
data class TopicChanged(val topic: String) : CreateSpaceAction()
|
||||
data class SpaceAliasChanged(val aliasLocalPart: String) : CreateSpaceAction()
|
||||
data class SetAvatar(val uri: Uri?) : CreateSpaceAction()
|
||||
object OnBackPressed : CreateSpaceAction()
|
||||
object NextFromDetails : CreateSpaceAction()
|
||||
|
|
|
@ -84,6 +84,10 @@ class CreateSpaceDetailsFragment @Inject constructor(
|
|||
sharedViewModel.handle(CreateSpaceAction.TopicChanged(newTopic))
|
||||
}
|
||||
|
||||
override fun setAliasLocalPart(aliasLocalPart: String) {
|
||||
sharedViewModel.handle(CreateSpaceAction.SpaceAliasChanged(aliasLocalPart))
|
||||
}
|
||||
|
||||
override fun onBackPressed(toolbarButton: Boolean): Boolean {
|
||||
sharedViewModel.handle(CreateSpaceAction.OnBackPressed)
|
||||
return true
|
||||
|
|
|
@ -27,4 +27,5 @@ sealed class CreateSpaceEvents : VectorViewEvents {
|
|||
data class FinishSuccess(val spaceId: String, val defaultRoomId: String?, val topology: SpaceTopology?) : CreateSpaceEvents()
|
||||
data class ShowModalError(val errorMessage: String) : CreateSpaceEvents()
|
||||
object HideModalLoading : CreateSpaceEvents()
|
||||
data class ShowModalLoading(val message: String?) : CreateSpaceEvents()
|
||||
}
|
||||
|
|
|
@ -28,6 +28,10 @@ data class CreateSpaceState(
|
|||
val step: Step = Step.ChooseType,
|
||||
val spaceType: SpaceType? = null,
|
||||
val spaceTopology: SpaceTopology? = null,
|
||||
val homeServerName: String? = null,
|
||||
val aliasLocalPart: String? = null,
|
||||
val aliasManuallyModified: Boolean = false,
|
||||
val aliasVerificationTask: Async<Boolean> = Uninitialized,
|
||||
val nameInlineError: String? = null,
|
||||
val defaultRooms: Map<Int /** position in form */, String?>? = null,
|
||||
val creationResult: Async<String> = Uninitialized
|
||||
|
|
|
@ -35,14 +35,25 @@ import im.vector.app.core.platform.VectorViewModel
|
|||
import im.vector.app.core.resources.StringProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure
|
||||
|
||||
class CreateSpaceViewModel @AssistedInject constructor(
|
||||
@Assisted initialState: CreateSpaceState,
|
||||
private val session: Session,
|
||||
private val stringProvider: StringProvider,
|
||||
private val createSpaceViewModelTask: CreateSpaceViewModelTask,
|
||||
private val errorFormatter: ErrorFormatter
|
||||
) : VectorViewModel<CreateSpaceState, CreateSpaceAction, CreateSpaceEvents>(initialState) {
|
||||
|
||||
init {
|
||||
setState {
|
||||
copy(
|
||||
homeServerName = session.myUserId.substringAfter(":")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(initialState: CreateSpaceState): CreateSpaceViewModel
|
||||
|
@ -80,10 +91,15 @@ class CreateSpaceViewModel @AssistedInject constructor(
|
|||
_viewEvents.post(CreateSpaceEvents.NavigateToDetails)
|
||||
}
|
||||
is CreateSpaceAction.NameChanged -> {
|
||||
val tentativeAlias =
|
||||
getAliasFromName(action.name)
|
||||
|
||||
setState {
|
||||
copy(
|
||||
nameInlineError = null,
|
||||
name = action.name
|
||||
name = action.name,
|
||||
aliasLocalPart = tentativeAlias,
|
||||
aliasVerificationTask = Uninitialized
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -94,6 +110,15 @@ class CreateSpaceViewModel @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
}
|
||||
is CreateSpaceAction.SpaceAliasChanged -> {
|
||||
setState {
|
||||
copy(
|
||||
aliasManuallyModified = true,
|
||||
aliasLocalPart = action.aliasLocalPart,
|
||||
aliasVerificationTask = Uninitialized
|
||||
)
|
||||
}
|
||||
}
|
||||
CreateSpaceAction.OnBackPressed -> {
|
||||
handleBackNavigation()
|
||||
}
|
||||
|
@ -121,6 +146,12 @@ class CreateSpaceViewModel @AssistedInject constructor(
|
|||
}.exhaustive
|
||||
}
|
||||
|
||||
private fun getAliasFromName(name: String): String {
|
||||
return Regex("\\s").replace(name.lowercase(), "_").let {
|
||||
"[^a-z0-9._%#@=+-]".toRegex().replace(it, "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetTopology(action: CreateSpaceAction.SetSpaceTopology) {
|
||||
when (action.topology) {
|
||||
SpaceTopology.JustMe -> {
|
||||
|
@ -204,12 +235,31 @@ class CreateSpaceViewModel @AssistedInject constructor(
|
|||
}
|
||||
_viewEvents.post(CreateSpaceEvents.NavigateToChoosePrivateType)
|
||||
} else {
|
||||
// it'a public space, let's check alias
|
||||
val aliasLocalPart = if (state.aliasManuallyModified) state.aliasLocalPart else getAliasFromName(state.name)
|
||||
_viewEvents.post(CreateSpaceEvents.ShowModalLoading(null))
|
||||
setState {
|
||||
copy(
|
||||
step = CreateSpaceState.Step.AddRooms
|
||||
copy(aliasVerificationTask = Loading())
|
||||
}
|
||||
viewModelScope.launch {
|
||||
session.checkAliasAvailability(aliasLocalPart).fold(
|
||||
{
|
||||
setState {
|
||||
copy(
|
||||
step = CreateSpaceState.Step.AddRooms
|
||||
)
|
||||
}
|
||||
_viewEvents.post(CreateSpaceEvents.HideModalLoading)
|
||||
_viewEvents.post(CreateSpaceEvents.NavigateToAddRooms)
|
||||
},
|
||||
{
|
||||
setState {
|
||||
copy(aliasVerificationTask = Fail(it))
|
||||
}
|
||||
_viewEvents.post(CreateSpaceEvents.HideModalLoading)
|
||||
}
|
||||
)
|
||||
}
|
||||
_viewEvents.post(CreateSpaceEvents.NavigateToAddRooms)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -221,6 +271,9 @@ class CreateSpaceViewModel @AssistedInject constructor(
|
|||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val alias = if (state.spaceType == SpaceType.Public) {
|
||||
if (state.aliasManuallyModified) state.aliasLocalPart else getAliasFromName(state.name)
|
||||
} else null
|
||||
val result = createSpaceViewModelTask.execute(
|
||||
CreateSpaceTaskParams(
|
||||
spaceName = spaceName,
|
||||
|
@ -230,7 +283,8 @@ class CreateSpaceViewModel @AssistedInject constructor(
|
|||
defaultRooms = state.defaultRooms
|
||||
?.entries
|
||||
?.sortedBy { it.key }
|
||||
?.mapNotNull { it.value } ?: emptyList()
|
||||
?.mapNotNull { it.value } ?: emptyList(),
|
||||
spaceAlias = alias
|
||||
)
|
||||
)
|
||||
when (result) {
|
||||
|
@ -260,10 +314,22 @@ class CreateSpaceViewModel @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
is CreateSpaceTaskResult.FailedToCreateSpace -> {
|
||||
setState {
|
||||
copy(creationResult = Fail(result.failure))
|
||||
if (result.failure is CreateRoomFailure.AliasError) {
|
||||
setState {
|
||||
copy(
|
||||
step = CreateSpaceState.Step.SetDetails,
|
||||
aliasVerificationTask = Fail(result.failure.aliasError),
|
||||
creationResult = Uninitialized
|
||||
)
|
||||
}
|
||||
_viewEvents.post(CreateSpaceEvents.HideModalLoading)
|
||||
_viewEvents.post(CreateSpaceEvents.NavigateToDetails)
|
||||
} else {
|
||||
setState {
|
||||
copy(creationResult = Fail(result.failure))
|
||||
}
|
||||
_viewEvents.post(CreateSpaceEvents.ShowModalError(errorFormatter.toHumanReadable(result.failure)))
|
||||
}
|
||||
_viewEvents.post(CreateSpaceEvents.ShowModalError(errorFormatter.toHumanReadable(result.failure)))
|
||||
}
|
||||
}
|
||||
} catch (failure: Throwable) {
|
||||
|
|
|
@ -45,6 +45,7 @@ data class CreateSpaceTaskParams(
|
|||
val spaceName: String,
|
||||
val spaceTopic: String?,
|
||||
val spaceAvatar: Uri? = null,
|
||||
val spaceAlias: String? = null,
|
||||
val isPublic: Boolean,
|
||||
val defaultRooms: List<String> = emptyList()
|
||||
)
|
||||
|
@ -57,7 +58,8 @@ class CreateSpaceViewModelTask @Inject constructor(
|
|||
|
||||
override suspend fun execute(params: CreateSpaceTaskParams): CreateSpaceTaskResult {
|
||||
val spaceID = try {
|
||||
session.spaceService().createSpace(params.spaceName, params.spaceTopic, params.spaceAvatar, params.isPublic)
|
||||
session.spaceService().createSpace(params.spaceName, params.spaceTopic, params.spaceAvatar,
|
||||
params.isPublic, params.spaceAlias)
|
||||
} catch (failure: Throwable) {
|
||||
return CreateSpaceTaskResult.FailedToCreateSpace(failure)
|
||||
}
|
||||
|
|
|
@ -17,24 +17,39 @@
|
|||
package im.vector.app.features.spaces.create
|
||||
|
||||
import com.airbnb.epoxy.TypedEpoxyController
|
||||
import com.airbnb.mvrx.Fail
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.epoxy.TextListener
|
||||
import im.vector.app.core.resources.StringProvider
|
||||
import im.vector.app.core.ui.list.genericFooterItem
|
||||
import im.vector.app.features.form.formEditTextItem
|
||||
import im.vector.app.features.form.formEditableSquareAvatarItem
|
||||
import im.vector.app.features.form.formMultiLineEditTextItem
|
||||
import im.vector.app.features.home.AvatarRenderer
|
||||
import im.vector.app.features.roomdirectory.createroom.RoomAliasErrorFormatter
|
||||
import org.matrix.android.sdk.api.session.room.alias.RoomAliasError
|
||||
import org.matrix.android.sdk.api.util.MatrixItem
|
||||
import javax.inject.Inject
|
||||
|
||||
class SpaceDetailEpoxyController @Inject constructor(
|
||||
private val stringProvider: StringProvider,
|
||||
private val avatarRenderer: AvatarRenderer
|
||||
private val avatarRenderer: AvatarRenderer,
|
||||
private val roomAliasErrorFormatter: RoomAliasErrorFormatter
|
||||
) : TypedEpoxyController<CreateSpaceState>() {
|
||||
|
||||
var listener: Listener? = null
|
||||
|
||||
// var shouldForceFocusOnce = true
|
||||
/**
|
||||
* Alias text can be automatically set when changing the room name,
|
||||
* We have to be able to make a difference between a programming change versus
|
||||
* a user change.
|
||||
*/
|
||||
var aliasTextIsFocused = false
|
||||
private val aliasTextWatcher: TextListener = {
|
||||
if (aliasTextIsFocused) {
|
||||
listener?.setAliasLocalPart(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun buildModels(data: CreateSpaceState?) {
|
||||
val host = this
|
||||
|
@ -65,20 +80,32 @@ class SpaceDetailEpoxyController @Inject constructor(
|
|||
value(data?.name)
|
||||
hint(host.stringProvider.getString(R.string.create_room_name_hint))
|
||||
errorMessage(data?.nameInlineError)
|
||||
// onBind { _, view, _ ->
|
||||
// if (shouldForceFocusOnce && data?.name.isNullOrBlank()) {
|
||||
// shouldForceFocusOnce = false
|
||||
// // sad face :(
|
||||
// view.textInputEditText.post {
|
||||
// view.textInputEditText.showKeyboard(true)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
onTextChange { text ->
|
||||
host.listener?.onNameChange(text)
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.spaceType == SpaceType.Public) {
|
||||
formEditTextItem {
|
||||
id("alias")
|
||||
enabled(true)
|
||||
forceUpdateValue(!data.aliasManuallyModified)
|
||||
value(data.aliasLocalPart)
|
||||
hint(host.stringProvider.getString(R.string.create_space_alias_hint))
|
||||
inputSuffix(":" + data.homeServerName)
|
||||
inputPrefix("#")
|
||||
onFocusChange { hasFocus ->
|
||||
host.aliasTextIsFocused = hasFocus
|
||||
}
|
||||
errorMessage(
|
||||
host.roomAliasErrorFormatter.format(
|
||||
(((data.aliasVerificationTask as? Fail)?.error) as? RoomAliasError))
|
||||
)
|
||||
onTextChange(host.aliasTextWatcher)
|
||||
showBottomSeparator(false)
|
||||
}
|
||||
}
|
||||
|
||||
formMultiLineEditTextItem {
|
||||
id("topic")
|
||||
enabled(true)
|
||||
|
@ -96,5 +123,6 @@ class SpaceDetailEpoxyController @Inject constructor(
|
|||
fun onAvatarChange()
|
||||
fun onNameChange(newName: String)
|
||||
fun onTopicChange(newTopic: String)
|
||||
fun setAliasLocalPart(aliasLocalPart: String)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2585,6 +2585,7 @@
|
|||
<string name="create_room_disable_federation_description">You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.</string>
|
||||
|
||||
<string name="create_room_alias_hint">Room address</string>
|
||||
<string name="create_space_alias_hint">Space address</string>
|
||||
<string name="create_room_alias_already_in_use">This address is already in use</string>
|
||||
<string name="create_room_alias_empty">Please provide a room address</string>
|
||||
<string name="create_room_alias_invalid">Some characters are not allowed</string>
|
||||
|
|
Loading…
Reference in New Issue