Handle even more instance defaults (#2612)
* handle media size instance limits * remove unused attributes from Instance entity * support max_media_attachments * support pleroma field limits, remove max_bio_chars support * improve field input margin * fix tests * MAX_ACCOUNT_FIELDS -> DEFAULT_MAX_ACCOUNT_FIELDS * improve "add field" button behavior * fix copy paste mistake in AccountFieldEditAdapter * refactor sendStatus to be a suspending function
This commit is contained in:
parent
25f637f0a8
commit
1b6a0908f6
16 changed files with 1219 additions and 308 deletions
|
@ -28,6 +28,7 @@ import android.widget.ImageView
|
|||
import androidx.activity.viewModels
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
|
@ -37,6 +38,7 @@ import com.canhub.cropper.CropImageContract
|
|||
import com.canhub.cropper.options
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
|
@ -50,6 +52,7 @@ import com.mikepenz.iconics.IconicsDrawable
|
|||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class EditProfileActivity : BaseActivity(), Injectable {
|
||||
|
@ -58,8 +61,6 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
const val AVATAR_SIZE = 400
|
||||
const val HEADER_WIDTH = 1500
|
||||
const val HEADER_HEIGHT = 500
|
||||
|
||||
private const val MAX_ACCOUNT_FIELDS = 4
|
||||
}
|
||||
|
||||
@Inject
|
||||
|
@ -71,6 +72,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
|
||||
private val accountFieldEditAdapter = AccountFieldEditAdapter()
|
||||
|
||||
private var maxAccountFields = InstanceInfoRepository.DEFAULT_MAX_ACCOUNT_FIELDS
|
||||
|
||||
private enum class PickType {
|
||||
AVATAR,
|
||||
HEADER
|
||||
|
@ -112,7 +115,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
|
||||
binding.addFieldButton.setOnClickListener {
|
||||
accountFieldEditAdapter.addField()
|
||||
if (accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) {
|
||||
if (accountFieldEditAdapter.itemCount >= maxAccountFields) {
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
|
@ -134,7 +137,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
binding.lockedCheckBox.isChecked = me.locked
|
||||
|
||||
accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList())
|
||||
binding.addFieldButton.isEnabled = me.source?.fields?.size ?: 0 < MAX_ACCOUNT_FIELDS
|
||||
binding.addFieldButton.isVisible =
|
||||
(me.source?.fields?.size ?: 0) < maxAccountFields
|
||||
|
||||
if (viewModel.avatarData.value == null) {
|
||||
Glide.with(this)
|
||||
|
@ -165,13 +169,12 @@ class EditProfileActivity : BaseActivity(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
viewModel.obtainInstance()
|
||||
viewModel.instanceData.observe(this) { result ->
|
||||
if (result is Success) {
|
||||
val instance = result.data
|
||||
if (instance?.maxBioChars != null && instance.maxBioChars > 0) {
|
||||
binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.instanceData.collect { instanceInfo ->
|
||||
maxAccountFields = instanceInfo.maxFields
|
||||
accountFieldEditAdapter.setFieldLimits(instanceInfo.maxFieldNameLength, instanceInfo.maxFieldValueLength)
|
||||
binding.addFieldButton.isVisible =
|
||||
accountFieldEditAdapter.itemCount < maxAccountFields
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,8 @@ import com.keylesspalace.tusky.util.BindingHolder
|
|||
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
|
||||
|
||||
private val fieldData = mutableListOf<MutableStringPair>()
|
||||
private var maxNameLength: Int? = null
|
||||
private var maxValueLength: Int? = null
|
||||
|
||||
fun setFields(fields: List<StringField>) {
|
||||
fieldData.clear()
|
||||
|
@ -41,6 +43,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
|||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun setFieldLimits(maxNameLength: Int?, maxValueLength: Int?) {
|
||||
this.maxNameLength = maxNameLength
|
||||
this.maxValueLength = maxValueLength
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun getFieldData(): List<StringField> {
|
||||
return fieldData.map {
|
||||
StringField(it.first, it.second)
|
||||
|
@ -60,10 +68,20 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
|||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemEditFieldBinding>, position: Int) {
|
||||
holder.binding.accountFieldName.setText(fieldData[position].first)
|
||||
holder.binding.accountFieldValue.setText(fieldData[position].second)
|
||||
holder.binding.accountFieldNameText.setText(fieldData[position].first)
|
||||
holder.binding.accountFieldValueText.setText(fieldData[position].second)
|
||||
|
||||
holder.binding.accountFieldName.addTextChangedListener(object : TextWatcher {
|
||||
holder.binding.accountFieldNameTextLayout.isCounterEnabled = maxNameLength != null
|
||||
maxNameLength?.let {
|
||||
holder.binding.accountFieldNameTextLayout.counterMaxLength = it
|
||||
}
|
||||
|
||||
holder.binding.accountFieldValueTextLayout.isCounterEnabled = maxValueLength != null
|
||||
maxValueLength?.let {
|
||||
holder.binding.accountFieldValueTextLayout.counterMaxLength = it
|
||||
}
|
||||
|
||||
holder.binding.accountFieldNameText.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(newText: Editable) {
|
||||
fieldData[holder.bindingAdapterPosition].first = newText.toString()
|
||||
}
|
||||
|
@ -73,7 +91,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
|||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
holder.binding.accountFieldValue.addTextChangedListener(object : TextWatcher {
|
||||
holder.binding.accountFieldValueText.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(newText: Editable) {
|
||||
fieldData[holder.bindingAdapterPosition].second = newText.toString()
|
||||
}
|
||||
|
|
|
@ -52,7 +52,6 @@ import androidx.core.view.ContentInfoCompat
|
|||
import androidx.core.view.OnReceiveContentListener
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
@ -85,8 +84,6 @@ import com.keylesspalace.tusky.settings.PrefKeys
|
|||
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.afterTextChanged
|
||||
import com.keylesspalace.tusky.util.combineLiveData
|
||||
import com.keylesspalace.tusky.util.combineOptionalLiveData
|
||||
import com.keylesspalace.tusky.util.getMediaSize
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.highlightSpans
|
||||
|
@ -95,11 +92,13 @@ import com.keylesspalace.tusky.util.onTextChanged
|
|||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.util.withLifecycleContext
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.File
|
||||
|
@ -138,8 +137,7 @@ class ComposeActivity :
|
|||
|
||||
private val binding by viewBinding(ActivityComposeBinding::inflate)
|
||||
|
||||
private val maxUploadMediaNumber = 4
|
||||
private var mediaCount = 0
|
||||
private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS
|
||||
|
||||
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (success) {
|
||||
|
@ -147,7 +145,7 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
|
||||
if (mediaCount + uris.size > maxUploadMediaNumber) {
|
||||
if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) {
|
||||
Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
uris.forEach { uri ->
|
||||
|
@ -224,8 +222,8 @@ class ComposeActivity :
|
|||
binding.composeMediaPreviewBar.adapter = mediaAdapter
|
||||
binding.composeMediaPreviewBar.itemAnimator = null
|
||||
|
||||
subscribeToUpdates(mediaAdapter)
|
||||
setupButtons()
|
||||
subscribeToUpdates(mediaAdapter)
|
||||
|
||||
photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY)
|
||||
|
||||
|
@ -363,36 +361,48 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
|
||||
withLifecycleContext {
|
||||
viewModel.instanceInfo.observe { instanceData ->
|
||||
lifecycleScope.launch {
|
||||
viewModel.instanceInfo.collect { instanceData ->
|
||||
maximumTootCharacters = instanceData.maxChars
|
||||
charactersReservedPerUrl = instanceData.charactersReservedPerUrl
|
||||
maxUploadMediaNumber = instanceData.maxMediaAttachments
|
||||
updateVisibleCharactersLeft()
|
||||
}
|
||||
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
|
||||
combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning ->
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.emoji.collect(::setEmojiList)
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.showContentWarning.combine(viewModel.markMediaAsSensitive) { showContentWarning, markSensitive ->
|
||||
updateSensitiveMediaToggle(markSensitive, showContentWarning)
|
||||
showContentWarning(showContentWarning)
|
||||
}.subscribe()
|
||||
viewModel.statusVisibility.observe { visibility ->
|
||||
setStatusVisibility(visibility)
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
viewModel.media.collect { media ->
|
||||
mediaAdapter.submitList(media)
|
||||
if (media.size != mediaCount) {
|
||||
mediaCount = media.size
|
||||
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
|
||||
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.collect()
|
||||
}
|
||||
|
||||
viewModel.poll.observe { poll ->
|
||||
lifecycleScope.launch {
|
||||
viewModel.statusVisibility.collect(::setStatusVisibility)
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.media.collect { media ->
|
||||
mediaAdapter.submitList(media)
|
||||
|
||||
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
|
||||
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.poll.collect { poll ->
|
||||
binding.pollPreview.visible(poll != null)
|
||||
poll?.let(binding.pollPreview::setPoll)
|
||||
}
|
||||
viewModel.scheduledAt.observe { scheduledAt ->
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.scheduledAt.collect { scheduledAt ->
|
||||
if (scheduledAt == null) {
|
||||
binding.composeScheduleView.resetSchedule()
|
||||
} else {
|
||||
|
@ -400,22 +410,30 @@ class ComposeActivity :
|
|||
}
|
||||
updateScheduleButton()
|
||||
}
|
||||
combineOptionalLiveData(viewModel.media.asLiveData(), viewModel.poll) { media, poll ->
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.media.combine(viewModel.poll) { media, poll ->
|
||||
val active = poll == null &&
|
||||
media!!.size != 4 &&
|
||||
media.size < maxUploadMediaNumber &&
|
||||
(media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
|
||||
enableButton(binding.composeAddMediaButton, active, active)
|
||||
enablePollButton(media.isNullOrEmpty())
|
||||
}.subscribe()
|
||||
viewModel.uploadError.observe { throwable ->
|
||||
Log.w(TAG, "media upload failed", throwable)
|
||||
enablePollButton(media.isEmpty())
|
||||
}.collect()
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.uploadError.collect { throwable ->
|
||||
if (throwable is UploadServerError) {
|
||||
displayTransientError(throwable.errorMessage)
|
||||
} else {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
}
|
||||
}
|
||||
viewModel.setupComplete.observe {
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.setupComplete.collect {
|
||||
// Focus may have changed during view model setup, ensure initial focus is on the edit field
|
||||
binding.composeEditField.requestFocus()
|
||||
}
|
||||
|
@ -711,13 +729,17 @@ class ComposeActivity :
|
|||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
private fun openPollDialog() {
|
||||
private fun openPollDialog() = lifecycleScope.launch {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
val instanceParams = viewModel.instanceInfo.value!!
|
||||
val instanceParams = viewModel.instanceInfo.first()
|
||||
showAddPollDialog(
|
||||
this, viewModel.poll.value, instanceParams.pollMaxOptions,
|
||||
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
|
||||
viewModel::updatePoll
|
||||
context = this@ComposeActivity,
|
||||
poll = viewModel.poll.value,
|
||||
maxOptionCount = instanceParams.pollMaxOptions,
|
||||
maxOptionLength = instanceParams.pollMaxLength,
|
||||
minDuration = instanceParams.pollMinDuration,
|
||||
maxDuration = instanceParams.pollMaxDuration,
|
||||
onUpdatePoll = viewModel::updatePoll
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -768,7 +790,7 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
var length = binding.composeEditField.length() - offset
|
||||
if (viewModel.showContentWarning.value!!) {
|
||||
if (viewModel.showContentWarning.value) {
|
||||
length += binding.composeContentWarningField.length()
|
||||
}
|
||||
return length
|
||||
|
@ -822,7 +844,7 @@ class ComposeActivity :
|
|||
enableButtons(false)
|
||||
val contentText = binding.composeEditField.text.toString()
|
||||
var spoilerText = ""
|
||||
if (viewModel.showContentWarning.value!!) {
|
||||
if (viewModel.showContentWarning.value) {
|
||||
spoilerText = binding.composeContentWarningField.text.toString()
|
||||
}
|
||||
val characterCount = calculateTextLength()
|
||||
|
@ -837,9 +859,8 @@ class ComposeActivity :
|
|||
)
|
||||
}
|
||||
|
||||
viewModel.sendStatus(contentText, spoilerText).observe(
|
||||
this
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
viewModel.sendStatus(contentText, spoilerText)
|
||||
finishingUploadDialog?.dismiss()
|
||||
deleteDraftAndFinish()
|
||||
}
|
||||
|
|
|
@ -18,10 +18,7 @@ package com.keylesspalace.tusky.components.compose
|
|||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
|
@ -38,30 +35,34 @@ import com.keylesspalace.tusky.entity.Status
|
|||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.service.StatusToSend
|
||||
import com.keylesspalace.tusky.util.combineLiveData
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import com.keylesspalace.tusky.util.toLiveData
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.rxSingle
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class ComposeViewModel @Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val mediaUploader: MediaUploader,
|
||||
private val serviceClient: ServiceClient,
|
||||
private val draftHelper: DraftHelper,
|
||||
private val instanceInfoRepo: InstanceInfoRepository
|
||||
instanceInfoRepo: InstanceInfoRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private var replyingStatusAuthor: String? = null
|
||||
|
@ -76,40 +77,32 @@ class ComposeViewModel @Inject constructor(
|
|||
private var contentWarningStateChanged: Boolean = false
|
||||
private var modifiedInitialState: Boolean = false
|
||||
|
||||
val instanceInfo: MutableLiveData<InstanceInfo> = MutableLiveData()
|
||||
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
|
||||
val markMediaAsSensitive =
|
||||
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN)
|
||||
val showContentWarning = mutableLiveData(false)
|
||||
val setupComplete = mutableLiveData(false)
|
||||
val poll: MutableLiveData<NewPoll?> = mutableLiveData(null)
|
||||
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null)
|
||||
val markMediaAsSensitive: MutableStateFlow<Boolean> =
|
||||
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
|
||||
val statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
|
||||
val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
val setupComplete: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
|
||||
val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
|
||||
|
||||
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
val uploadError = MutableLiveData<Throwable>()
|
||||
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
private val mediaToJob = mutableMapOf<Int, Job>()
|
||||
|
||||
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
|
||||
|
||||
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
emoji.postValue(instanceInfoRepo.getEmojis())
|
||||
}
|
||||
viewModelScope.launch {
|
||||
instanceInfo.postValue(instanceInfoRepo.getInstanceInfo())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri)
|
||||
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
|
||||
val mediaItems = media.value
|
||||
if (type != QueuedMedia.Type.IMAGE &&
|
||||
mediaItems.isNotEmpty() &&
|
||||
|
@ -157,10 +150,10 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
mediaToJob[mediaItem.localId] = viewModelScope.launch {
|
||||
mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
.uploadMedia(mediaItem, instanceInfo.first())
|
||||
.catch { error ->
|
||||
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
|
||||
uploadError.postValue(error)
|
||||
uploadError.emit(error)
|
||||
}
|
||||
.collect { event ->
|
||||
val item = media.value.find { it.localId == mediaItem.localId }
|
||||
|
@ -216,7 +209,7 @@ class ComposeViewModel @Inject constructor(
|
|||
startingText?.startsWith(content.toString()) ?: false
|
||||
)
|
||||
|
||||
val contentWarningChanged = showContentWarning.value!! &&
|
||||
val contentWarningChanged = showContentWarning.value &&
|
||||
!contentWarning.isNullOrEmpty() &&
|
||||
!startingContentWarning.startsWith(contentWarning.toString())
|
||||
val mediaChanged = media.value.isNotEmpty()
|
||||
|
@ -259,8 +252,8 @@ class ComposeViewModel @Inject constructor(
|
|||
inReplyToId = inReplyToId,
|
||||
content = content,
|
||||
contentWarning = contentWarning,
|
||||
sensitive = markMediaAsSensitive.value!!,
|
||||
visibility = statusVisibility.value!!,
|
||||
sensitive = markMediaAsSensitive.value,
|
||||
visibility = statusVisibility.value,
|
||||
mediaUris = mediaUris,
|
||||
mediaDescriptions = mediaDescriptions,
|
||||
poll = poll.value,
|
||||
|
@ -271,38 +264,34 @@ class ComposeViewModel @Inject constructor(
|
|||
/**
|
||||
* Send status to the server.
|
||||
* Uses current state plus provided arguments.
|
||||
* @return LiveData which will signal once the screen can be closed or null if there are errors
|
||||
*/
|
||||
fun sendStatus(
|
||||
suspend fun sendStatus(
|
||||
content: String,
|
||||
spoilerText: String
|
||||
): LiveData<Unit> {
|
||||
) {
|
||||
|
||||
val deletionObservable = if (isEditingScheduledToot) {
|
||||
rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { }
|
||||
} else {
|
||||
Observable.just(Unit)
|
||||
}.toLiveData()
|
||||
if (!scheduledTootId.isNullOrEmpty()) {
|
||||
api.deleteScheduledStatus(scheduledTootId!!)
|
||||
}
|
||||
|
||||
val sendFlow = media
|
||||
media
|
||||
.filter { items -> items.all { it.uploadPercent == -1 } }
|
||||
.map {
|
||||
.first {
|
||||
val mediaIds: MutableList<String> = mutableListOf()
|
||||
val mediaUris: MutableList<Uri> = mutableListOf()
|
||||
val mediaDescriptions: MutableList<String> = mutableListOf()
|
||||
val mediaProcessed: MutableList<Boolean> = mutableListOf()
|
||||
for (item in media.value) {
|
||||
media.value.forEach { item ->
|
||||
mediaIds.add(item.id!!)
|
||||
mediaUris.add(item.uri)
|
||||
mediaDescriptions.add(item.description ?: "")
|
||||
mediaProcessed.add(false)
|
||||
}
|
||||
|
||||
val tootToSend = StatusToSend(
|
||||
text = content,
|
||||
warningText = spoilerText,
|
||||
visibility = statusVisibility.value!!.serverString(),
|
||||
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!),
|
||||
visibility = statusVisibility.value.serverString(),
|
||||
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
|
||||
mediaIds = mediaIds,
|
||||
mediaUris = mediaUris.map { it.toString() },
|
||||
mediaDescriptions = mediaDescriptions,
|
||||
|
@ -319,9 +308,8 @@ class ComposeViewModel @Inject constructor(
|
|||
)
|
||||
|
||||
serviceClient.sendToot(tootToSend)
|
||||
true
|
||||
}
|
||||
|
||||
return combineLiveData(deletionObservable, sendFlow.asLiveData()) { _, _ -> }
|
||||
}
|
||||
|
||||
suspend fun updateDescription(localId: Int, description: String): Boolean {
|
||||
|
@ -369,7 +357,7 @@ class ComposeViewModel @Inject constructor(
|
|||
})
|
||||
}
|
||||
':' -> {
|
||||
val emojiList = emoji.value ?: return emptyList()
|
||||
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
|
||||
val incomplete = token.substring(1)
|
||||
|
||||
return emojiList.filter { emoji ->
|
||||
|
@ -389,7 +377,7 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
|
||||
|
||||
if (setupComplete.value == true) {
|
||||
if (setupComplete.value) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -476,8 +464,6 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
|
||||
|
||||
/**
|
||||
* Thrown when trying to add an image when video is already present or the other way around
|
||||
*/
|
||||
|
|
|
@ -27,6 +27,7 @@ import at.connyduck.calladapter.networkresult.fold
|
|||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
|
||||
import com.keylesspalace.tusky.network.MediaUploadApi
|
||||
import com.keylesspalace.tusky.network.ProgressRequestBody
|
||||
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
|
||||
|
@ -82,10 +83,10 @@ class MediaUploader @Inject constructor(
|
|||
) {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> {
|
||||
fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> {
|
||||
return flow {
|
||||
if (shouldResizeMedia(media)) {
|
||||
emit(downsize(media))
|
||||
if (shouldResizeMedia(media, instanceInfo)) {
|
||||
emit(downsize(media, instanceInfo))
|
||||
} else {
|
||||
emit(media)
|
||||
}
|
||||
|
@ -94,7 +95,7 @@ class MediaUploader @Inject constructor(
|
|||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
fun prepareMedia(inUri: Uri): PreparedMedia {
|
||||
fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
|
||||
var mediaSize = MEDIA_SIZE_UNKNOWN
|
||||
var uri = inUri
|
||||
val mimeType: String?
|
||||
|
@ -164,7 +165,7 @@ class MediaUploader @Inject constructor(
|
|||
if (mimeType != null) {
|
||||
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
|
||||
"video" -> {
|
||||
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
|
||||
if (mediaSize > instanceInfo.videoSizeLimit) {
|
||||
throw VideoSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
||||
|
@ -173,7 +174,7 @@ class MediaUploader @Inject constructor(
|
|||
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
||||
}
|
||||
"audio" -> {
|
||||
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
|
||||
if (mediaSize > instanceInfo.videoSizeLimit) {
|
||||
throw AudioSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
|
||||
|
@ -239,22 +240,18 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun downsize(media: QueuedMedia): QueuedMedia {
|
||||
private fun downsize(media: QueuedMedia, instanceInfo: InstanceInfo): QueuedMedia {
|
||||
val file = createNewImageFile(context)
|
||||
downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file)
|
||||
downsizeImage(media.uri, instanceInfo.imageSizeLimit, contentResolver, file)
|
||||
return media.copy(uri = file.toUri(), mediaSize = file.length())
|
||||
}
|
||||
|
||||
private fun shouldResizeMedia(media: QueuedMedia): Boolean {
|
||||
private fun shouldResizeMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Boolean {
|
||||
return media.type == QueuedMedia.Type.IMAGE &&
|
||||
(media.mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
|
||||
(media.mediaSize > instanceInfo.imageSizeLimit || getImageSquarePixels(context.contentResolver, media.uri) > instanceInfo.imageMatrixLimit)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "MediaUploader"
|
||||
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
|
||||
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,5 +21,12 @@ data class InstanceInfo(
|
|||
val pollMaxLength: Int,
|
||||
val pollMinDuration: Int,
|
||||
val pollMaxDuration: Int,
|
||||
val charactersReservedPerUrl: Int
|
||||
val charactersReservedPerUrl: Int,
|
||||
val videoSizeLimit: Int,
|
||||
val imageSizeLimit: Int,
|
||||
val imageMatrixLimit: Int,
|
||||
val maxMediaAttachments: Int,
|
||||
val maxFields: Int,
|
||||
val maxFieldNameLength: Int?,
|
||||
val maxFieldValueLength: Int?
|
||||
)
|
||||
|
|
|
@ -69,7 +69,14 @@ class InstanceInfoRepository @Inject constructor(
|
|||
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
|
||||
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
|
||||
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
|
||||
version = instance.version
|
||||
version = instance.version,
|
||||
videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit,
|
||||
imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit,
|
||||
imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit,
|
||||
maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments,
|
||||
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
|
||||
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
|
||||
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength
|
||||
)
|
||||
dao.insertOrReplace(instanceEntity)
|
||||
instanceEntity
|
||||
|
@ -85,7 +92,14 @@ class InstanceInfoRepository @Inject constructor(
|
|||
pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
|
||||
pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
|
||||
charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL
|
||||
charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
|
||||
videoSizeLimit = instanceInfo?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT,
|
||||
imageSizeLimit = instanceInfo?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT,
|
||||
imageMatrixLimit = instanceInfo?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT,
|
||||
maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
|
||||
maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
|
||||
maxFieldNameLength = instanceInfo?.maxFieldNameLength,
|
||||
maxFieldValueLength = instanceInfo?.maxFieldValueLength
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -99,7 +113,14 @@ class InstanceInfoRepository @Inject constructor(
|
|||
private const val DEFAULT_MIN_POLL_DURATION = 300
|
||||
private const val DEFAULT_MAX_POLL_DURATION = 604800
|
||||
|
||||
private const val DEFAULT_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val DEFAULT_IMAGE_SIZE_LIMIT = 10485760 // 10MiB
|
||||
private const val DEFAULT_IMAGE_MATRIX_LIMIT = 16777216 // 4096^2 Pixels
|
||||
|
||||
// Mastodon only counts URLs as this long in terms of status character limits
|
||||
const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23
|
||||
|
||||
const val DEFAULT_MAX_MEDIA_ATTACHMENTS = 4
|
||||
const val DEFAULT_MAX_ACCOUNT_FIELDS = 4
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ import java.io.File;
|
|||
*/
|
||||
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class, ConversationEntity.class
|
||||
}, version = 39)
|
||||
}, version = 40)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public abstract AccountDao accountDao();
|
||||
|
@ -581,4 +581,17 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_39_40 = new Migration(39, 40) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `videoSizeLimit` INTEGER");
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageSizeLimit` INTEGER");
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageMatrixLimit` INTEGER");
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxMediaAttachments` INTEGER");
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFields` INTEGER");
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldNameLength` INTEGER");
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldValueLength` INTEGER");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -31,7 +31,14 @@ data class InstanceEntity(
|
|||
val minPollDuration: Int?,
|
||||
val maxPollDuration: Int?,
|
||||
val charactersReservedPerUrl: Int?,
|
||||
val version: String?
|
||||
val version: String?,
|
||||
val videoSizeLimit: Int?,
|
||||
val imageSizeLimit: Int?,
|
||||
val imageMatrixLimit: Int?,
|
||||
val maxMediaAttachments: Int?,
|
||||
val maxFields: Int?,
|
||||
val maxFieldNameLength: Int?,
|
||||
val maxFieldValueLength: Int?
|
||||
)
|
||||
|
||||
@TypeConverters(Converters::class)
|
||||
|
@ -48,5 +55,12 @@ data class InstanceInfoEntity(
|
|||
val minPollDuration: Int?,
|
||||
val maxPollDuration: Int?,
|
||||
val charactersReservedPerUrl: Int?,
|
||||
val version: String?
|
||||
val version: String?,
|
||||
val videoSizeLimit: Int?,
|
||||
val imageSizeLimit: Int?,
|
||||
val imageMatrixLimit: Int?,
|
||||
val maxMediaAttachments: Int?,
|
||||
val maxFields: Int?,
|
||||
val maxFieldNameLength: Int?,
|
||||
val maxFieldValueLength: Int?
|
||||
)
|
||||
|
|
|
@ -65,7 +65,7 @@ class AppModule {
|
|||
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
|
||||
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
|
||||
AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
|
||||
AppDatabase.MIGRATION_38_39
|
||||
AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
|
|
@ -19,19 +19,20 @@ import com.google.gson.annotations.SerializedName
|
|||
|
||||
data class Instance(
|
||||
val uri: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val email: String,
|
||||
// val title: String,
|
||||
// val description: String,
|
||||
// val email: String,
|
||||
val version: String,
|
||||
val urls: Map<String, String>,
|
||||
val stats: Map<String, Int>?,
|
||||
val thumbnail: String?,
|
||||
val languages: List<String>,
|
||||
@SerializedName("contact_account") val contactAccount: Account,
|
||||
// val urls: Map<String, String>,
|
||||
// val stats: Map<String, Int>?,
|
||||
// val thumbnail: String?,
|
||||
// val languages: List<String>,
|
||||
// @SerializedName("contact_account") val contactAccount: Account,
|
||||
@SerializedName("max_toot_chars") val maxTootChars: Int?,
|
||||
@SerializedName("max_bio_chars") val maxBioChars: Int?,
|
||||
@SerializedName("poll_limits") val pollConfiguration: PollConfiguration?,
|
||||
val configuration: InstanceConfiguration?,
|
||||
@SerializedName("max_media_attachments") val maxMediaAttachments: Int?,
|
||||
val pleroma: PleromaConfiguration?
|
||||
) {
|
||||
override fun hashCode(): Int {
|
||||
return uri.hashCode()
|
||||
|
@ -74,3 +75,17 @@ data class MediaAttachmentConfiguration(
|
|||
@SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?,
|
||||
@SerializedName("video_matrix_limit") val videoMatrixLimit: Int?,
|
||||
)
|
||||
|
||||
data class PleromaConfiguration(
|
||||
val metadata: PleromaMetadata?
|
||||
)
|
||||
|
||||
data class PleromaMetadata(
|
||||
@SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits
|
||||
)
|
||||
|
||||
data class PleromaFieldLimits(
|
||||
@SerializedName("max_fields") val maxFields: Int?,
|
||||
@SerializedName("name_length") val nameLength: Int?,
|
||||
@SerializedName("value_length") val valueLength: Int?
|
||||
)
|
||||
|
|
|
@ -1,98 +0,0 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.LiveDataReactiveStreams
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.Transformations
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
|
||||
inline fun <X, Y> LiveData<X>.map(crossinline mapFunction: (X) -> Y): LiveData<Y> =
|
||||
Transformations.map(this) { input -> mapFunction(input) }
|
||||
|
||||
inline fun <X, Y> LiveData<X>.switchMap(
|
||||
crossinline switchMapFunction: (X) -> LiveData<Y>
|
||||
): LiveData<Y> = Transformations.switchMap(this) { input -> switchMapFunction(input) }
|
||||
|
||||
inline fun <X> LiveData<X>.filter(crossinline predicate: (X) -> Boolean): LiveData<X> {
|
||||
val liveData = MediatorLiveData<X>()
|
||||
liveData.addSource(this) { value ->
|
||||
if (predicate(value)) {
|
||||
liveData.value = value
|
||||
}
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
fun LifecycleOwner.withLifecycleContext(body: LifecycleContext.() -> Unit) =
|
||||
LifecycleContext(this).apply(body)
|
||||
|
||||
class LifecycleContext(val lifecycleOwner: LifecycleOwner) {
|
||||
inline fun <T> LiveData<T>.observe(crossinline observer: (T) -> Unit) =
|
||||
this.observe(lifecycleOwner, Observer { observer(it) })
|
||||
|
||||
/**
|
||||
* Just hold a subscription,
|
||||
*/
|
||||
fun <T> LiveData<T>.subscribe() =
|
||||
this.observe(lifecycleOwner, Observer { })
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes @param [combiner] when value of both @param [a] and @param [b] are not null. Returns
|
||||
* [LiveData] with value set to the result of calling [combiner] with value of both.
|
||||
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
|
||||
*/
|
||||
fun <A, B, R> combineLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A, B) -> R): LiveData<R> {
|
||||
val liveData = MediatorLiveData<R>()
|
||||
liveData.addSource(a) {
|
||||
if (a.value != null && b.value != null) {
|
||||
liveData.value = combiner(a.value!!, b.value!!)
|
||||
}
|
||||
}
|
||||
liveData.addSource(b) {
|
||||
if (a.value != null && b.value != null) {
|
||||
liveData.value = combiner(a.value!!, b.value!!)
|
||||
}
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [LiveData] with value set to the result of calling [combiner] with value of [a] and [b]
|
||||
* after either changes. Doesn't check if either has value.
|
||||
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
|
||||
*/
|
||||
fun <A, B, R> combineOptionalLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A?, B?) -> R): LiveData<R> {
|
||||
val liveData = MediatorLiveData<R>()
|
||||
liveData.addSource(a) {
|
||||
liveData.value = combiner(a.value, b.value)
|
||||
}
|
||||
liveData.addSource(b) {
|
||||
liveData.value = combiner(a.value, b.value)
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
fun <T> Single<T>.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable())
|
||||
fun <T> Observable<T>.toLiveData(
|
||||
backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST
|
||||
) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST))
|
|
@ -24,8 +24,9 @@ import androidx.lifecycle.viewModelScope
|
|||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Instance
|
||||
import com.keylesspalace.tusky.entity.StringField
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Error
|
||||
|
@ -34,6 +35,11 @@ import com.keylesspalace.tusky.util.Resource
|
|||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.getServerErrorMessage
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
|
@ -49,14 +55,18 @@ private const val AVATAR_FILE_NAME = "avatar.png"
|
|||
class EditProfileViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
private val application: Application
|
||||
private val application: Application,
|
||||
private val instanceInfoRepo: InstanceInfoRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val profileData = MutableLiveData<Resource<Account>>()
|
||||
val avatarData = MutableLiveData<Uri>()
|
||||
val headerData = MutableLiveData<Uri>()
|
||||
val saveData = MutableLiveData<Resource<Nothing>>()
|
||||
val instanceData = MutableLiveData<Resource<Instance>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val instanceData: Flow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
|
||||
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
private var oldProfileData: Account? = null
|
||||
|
||||
|
@ -186,19 +196,4 @@ class EditProfileViewModel @Inject constructor(
|
|||
private fun getCacheFileForName(filename: String): File {
|
||||
return File(application.cacheDir, filename)
|
||||
}
|
||||
|
||||
fun obtainInstance() = viewModelScope.launch {
|
||||
if (instanceData.value == null || instanceData.value is Error) {
|
||||
instanceData.postValue(Loading())
|
||||
|
||||
mastodonApi.getInstance().fold(
|
||||
{ instance ->
|
||||
instanceData.postValue(Success(instance))
|
||||
},
|
||||
{
|
||||
instanceData.postValue(Error())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue