Tusky-App-Android/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt

592 lines
22 KiB
Kotlin

/* 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.components.compose
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.MediaToSend
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
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.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper,
instanceInfoRepo: InstanceInfoRepository
) : ViewModel() {
private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null
internal var startingText: String? = null
internal var postLanguage: String? = null
private var draftId: Int = 0
private var scheduledTootId: String? = null
private var startingContentWarning: String = ""
private var inReplyToId: String? = null
private var originalStatusId: String? = null
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
private var contentWarningStateChanged: Boolean = false
private var modifiedInitialState: Boolean = false
private var hasScheduledTimeChanged: Boolean = false
private var currentContent: String? = ""
private var currentContentWarning: String? = ""
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
private val _markMediaAsSensitive =
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val markMediaAsSensitive: StateFlow<Boolean> = _markMediaAsSensitive.asStateFlow()
private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN)
val statusVisibility: StateFlow<Status.Visibility> = _statusVisibility.asStateFlow()
private val _showContentWarning = MutableStateFlow(false)
val showContentWarning: StateFlow<Boolean> = _showContentWarning.asStateFlow()
private val _poll = MutableStateFlow(null as NewPoll?)
val poll: StateFlow<NewPoll?> = _poll.asStateFlow()
private val _scheduledAt = MutableStateFlow(null as String?)
val scheduledAt: StateFlow<String?> = _scheduledAt.asStateFlow()
private val _media = MutableStateFlow(emptyList<QueuedMedia>())
val media: StateFlow<List<QueuedMedia>> = _media.asStateFlow()
val uploadError =
MutableSharedFlow<Throwable>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val _closeConfirmation = MutableStateFlow(ConfirmationKind.NONE)
val closeConfirmation: StateFlow<ConfirmationKind> = _closeConfirmation.asStateFlow()
private lateinit var composeKind: ComposeKind
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null
private var setupComplete = false
suspend fun pickMedia(
mediaUri: Uri,
description: String? = null,
focus: Attachment.Focus? = null
): Result<QueuedMedia> = withContext(
Dispatchers.IO
) {
try {
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
val mediaItems = _media.value
if (type != QueuedMedia.Type.IMAGE &&
mediaItems.isNotEmpty() &&
mediaItems[0].type == QueuedMedia.Type.IMAGE
) {
Result.failure(VideoOrImageException())
} else {
val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
Result.success(queuedMedia)
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun addMediaToQueue(
type: QueuedMedia.Type,
uri: Uri,
mediaSize: Long,
description: String? = null,
focus: Attachment.Focus? = null,
replaceItem: QueuedMedia? = null
): QueuedMedia {
var stashMediaItem: QueuedMedia? = null
_media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
type = type,
mediaSize = mediaSize,
description = description,
focus = focus,
state = QueuedMedia.State.UPLOADING
)
stashMediaItem = mediaItem
if (replaceItem != null) {
mediaUploader.cancelUploadScope(replaceItem.localId)
mediaList.map {
if (it.localId == replaceItem.localId) mediaItem else it
}
} else { // Append
mediaList + mediaItem
}
}
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
viewModelScope.launch {
mediaUploader
.uploadMedia(mediaItem, instanceInfo.first())
.collect { event ->
val item = _media.value.find { it.localId == mediaItem.localId }
?: return@collect
val newMediaItem = when (event) {
is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent ->
item.copy(
id = event.mediaId,
uploadPercent = -1,
state = if (event.processed) {
QueuedMedia.State.PROCESSED
} else {
QueuedMedia.State.UNPROCESSED
}
)
is UploadEvent.ErrorEvent -> {
_media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
uploadError.emit(event.error)
return@collect
}
}
_media.update { mediaList ->
mediaList.map { mediaItem ->
if (mediaItem.localId == newMediaItem.localId) {
newMediaItem
} else {
mediaItem
}
}
}
}
}
updateCloseConfirmation()
return mediaItem
}
fun changeStatusVisibility(visibility: Status.Visibility) {
_statusVisibility.value = visibility
}
private fun addUploadedMedia(
id: String,
type: QueuedMedia.Type,
uri: Uri,
description: String?,
focus: Attachment.Focus?
) {
_media.update { mediaList ->
val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(),
uri = uri,
type = type,
mediaSize = 0,
uploadPercent = -1,
id = id,
description = description,
focus = focus,
state = QueuedMedia.State.PUBLISHED
)
mediaList + mediaItem
}
}
fun removeMediaFromQueue(item: QueuedMedia) {
mediaUploader.cancelUploadScope(item.localId)
_media.update { mediaList -> mediaList.filter { it.localId != item.localId } }
updateCloseConfirmation()
}
fun toggleMarkSensitive() {
this._markMediaAsSensitive.value = this._markMediaAsSensitive.value != true
}
fun updateContent(newContent: String?) {
currentContent = newContent
updateCloseConfirmation()
}
fun updateContentWarning(newContentWarning: String?) {
currentContentWarning = newContentWarning
updateCloseConfirmation()
}
private fun updateCloseConfirmation() {
val contentWarning = if (_showContentWarning.value) {
currentContentWarning
} else {
""
}
this._closeConfirmation.value = if (didChange(currentContent, contentWarning)) {
when (composeKind) {
ComposeKind.NEW -> if (isEmpty(currentContent, contentWarning)) {
ConfirmationKind.NONE
} else {
ConfirmationKind.SAVE_OR_DISCARD
}
ComposeKind.EDIT_DRAFT -> if (isEmpty(currentContent, contentWarning)) {
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
} else {
ConfirmationKind.UPDATE_OR_DISCARD
}
ComposeKind.EDIT_POSTED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
ComposeKind.EDIT_SCHEDULED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
}
} else {
ConfirmationKind.NONE
}
}
private fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = content.orEmpty() != startingText.orEmpty()
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
val mediaChanged = _media.value.isNotEmpty()
val pollChanged = _poll.value != null
val didScheduledTimeChange = hasScheduledTimeChanged
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
}
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && _media.value.isEmpty() && _poll.value == null)
}
fun contentWarningChanged(value: Boolean) {
_showContentWarning.value = value
contentWarningStateChanged = true
updateCloseConfirmation()
}
fun deleteDraft() {
viewModelScope.launch {
if (draftId != 0) {
draftHelper.deleteDraftAndAttachments(draftId)
}
}
}
fun stopUploads() {
mediaUploader.cancelUploadScope(*_media.value.map { it.localId }.toIntArray())
}
fun shouldShowSaveDraftDialog(): Boolean {
// if any of the media files need to be downloaded first it could take a while, so show a loading dialog
return _media.value.any { mediaValue ->
mediaValue.uri.scheme == "https"
}
}
suspend fun saveDraft(content: String, contentWarning: String) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
for (item in _media.value) {
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
mediaFocus.add(item.focus)
}
draftHelper.saveDraft(
draftId = draftId,
accountId = accountManager.activeAccount?.id!!,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = _markMediaAsSensitive.value,
visibility = _statusVisibility.value,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
poll = _poll.value,
failedToSend = false,
failedToSendAlert = false,
scheduledAt = _scheduledAt.value,
language = postLanguage,
statusId = originalStatusId
)
}
/**
* Send status to the server.
* Uses current state plus provided arguments.
*/
suspend fun sendStatus(content: String, spoilerText: String, accountId: Long) {
if (!scheduledTootId.isNullOrEmpty()) {
api.deleteScheduledStatus(scheduledTootId!!)
}
val attachedMedia = _media.value.map { item ->
MediaToSend(
localId = item.localId,
id = item.id,
uri = item.uri.toString(),
description = item.description,
focus = item.focus,
processed = item.state == QueuedMedia.State.PROCESSED || item.state == QueuedMedia.State.PUBLISHED
)
}
val tootToSend = StatusToSend(
text = content,
warningText = spoilerText,
visibility = _statusVisibility.value.serverString(),
sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value),
media = attachedMedia,
scheduledAt = _scheduledAt.value,
inReplyToId = inReplyToId,
poll = _poll.value,
replyingStatusContent = null,
replyingStatusAuthorUsername = null,
accountId = accountId,
draftId = draftId,
idempotencyKey = randomAlphanumericString(16),
retries = 0,
language = postLanguage,
statusId = originalStatusId
)
serviceClient.sendToot(tootToSend)
}
private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
_media.update { mediaList ->
mediaList.map { mediaItem ->
if (mediaItem.localId == localId) {
mutator(mediaItem)
} else {
mediaItem
}
}
}
}
fun updateDescription(localId: Int, description: String) {
updateMediaItem(localId) { mediaItem ->
mediaItem.copy(description = description)
}
}
fun updateFocus(localId: Int, focus: Attachment.Focus) {
updateMediaItem(localId) { mediaItem ->
mediaItem.copy(focus = focus)
}
}
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
return when (token[0]) {
'@' -> runBlocking {
api.searchAccounts(query = token.substring(1), limit = 10)
.fold({ accounts ->
accounts.map { AutocompleteResult.AccountResult(it) }
}, { e ->
Log.e(TAG, "Autocomplete search for $token failed.", e)
emptyList()
})
}
'#' -> runBlocking {
api.search(
query = token,
type = SearchType.Hashtag.apiParameter,
limit = 10
)
.fold({ searchResult ->
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
}, { e ->
Log.e(TAG, "Autocomplete search for $token failed.", e)
emptyList()
})
}
':' -> {
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
val incomplete = token.substring(1)
emojiList.filter { emoji ->
emoji.shortcode.contains(incomplete, ignoreCase = true)
}.sortedBy { emoji ->
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
}.map { emoji ->
AutocompleteResult.EmojiResult(emoji)
}
}
else -> {
Log.w(TAG, "Unexpected autocompletion token: $token")
emptyList()
}
}
}
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
if (setupComplete) {
return
}
composeKind = composeOptions?.kind ?: ComposeKind.NEW
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
startingVisibility = Status.Visibility.byNum(
preferredVisibility.num.coerceAtLeast(replyVisibility.num)
)
inReplyToId = composeOptions?.inReplyToId
modifiedInitialState = composeOptions?.modifiedInitialState == true
val contentWarning = composeOptions?.contentWarning
if (contentWarning != null) {
startingContentWarning = contentWarning
}
if (!contentWarningStateChanged) {
_showContentWarning.value = !contentWarning.isNullOrBlank()
}
// recreate media list
val draftAttachments = composeOptions?.draftAttachments
if (draftAttachments != null) {
// when coming from DraftActivity
viewModelScope.launch {
draftAttachments.forEach { attachment ->
pickMedia(attachment.uri, attachment.description, attachment.focus)
}
}
} else {
composeOptions?.mediaAttachments?.forEach { a ->
// when coming from redraft or ScheduledTootActivity
val mediaType = when (a.type) {
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
}
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
}
}
draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId
originalStatusId = composeOptions?.statusId
startingText = composeOptions?.content
postLanguage = composeOptions?.language
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
startingVisibility = tootVisibility
}
_statusVisibility.value = startingVisibility
val mentionedUsernames = composeOptions?.mentionedUsernames
if (mentionedUsernames != null) {
val builder = StringBuilder()
for (name in mentionedUsernames) {
builder.append('@')
builder.append(name)
builder.append(' ')
}
startingText = builder.toString()
}
_scheduledAt.value = composeOptions?.scheduledAt
composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it }
val poll = composeOptions?.poll
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
this._poll.value = poll
}
replyingStatusContent = composeOptions?.replyingStatusContent
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
updateCloseConfirmation()
setupComplete = true
}
fun updatePoll(newPoll: NewPoll?) {
_poll.value = newPoll
updateCloseConfirmation()
}
fun updateScheduledAt(newScheduledAt: String?) {
if (newScheduledAt != _scheduledAt.value) {
hasScheduledTimeChanged = true
}
_scheduledAt.value = newScheduledAt
}
val editing: Boolean
get() = !originalStatusId.isNullOrEmpty()
private companion object {
const val TAG = "ComposeViewModel"
}
enum class ConfirmationKind {
NONE, // just close
SAVE_OR_DISCARD,
UPDATE_OR_DISCARD,
CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post
CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft
}
}
/**
* Thrown when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()