diff --git a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt index ec26823a37..69578e3a40 100644 --- a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt +++ b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt @@ -59,7 +59,9 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager import app.pachli.BuildConfig @@ -80,8 +82,10 @@ import app.pachli.core.common.extensions.visible import app.pachli.core.common.string.mastodonLength import app.pachli.core.common.util.unsafeLazy import app.pachli.core.data.repository.Loadable +import app.pachli.core.data.repository.PachliAccount import app.pachli.core.database.model.AccountEntity import app.pachli.core.designsystem.R as DR +import app.pachli.core.model.InstanceInfo import app.pachli.core.model.InstanceInfo.Companion.DEFAULT_CHARACTER_LIMIT import app.pachli.core.model.InstanceInfo.Companion.DEFAULT_MAX_MEDIA_ATTACHMENTS import app.pachli.core.navigation.ComposeActivityIntent @@ -101,7 +105,6 @@ import app.pachli.databinding.ActivityComposeBinding import app.pachli.languageidentification.LanguageIdentifier import app.pachli.languageidentification.UNDETERMINED_LANGUAGE_TAG import app.pachli.util.PickMediaFiles -import app.pachli.util.getInitialLanguages import app.pachli.util.getLocaleList import app.pachli.util.getMediaSize import app.pachli.util.highlightSpans @@ -123,7 +126,6 @@ import com.google.android.material.snackbar.Snackbar import com.mikepenz.iconics.IconicsSize import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.withCreationCallback import java.io.File import java.io.IOException import java.util.Date @@ -133,7 +135,8 @@ import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import timber.log.Timber @@ -151,6 +154,9 @@ class ComposeActivity : OnReceiveContentListener, ComposeScheduleView.OnTimeSetListener { + /** The active snackbar */ + private var snackbar: Snackbar? = null + private lateinit var visibilityBehavior: BottomSheetBehavior<*> private lateinit var addAttachmentBehavior: BottomSheetBehavior<*> private lateinit var emojiBehavior: BottomSheetBehavior<*> @@ -172,16 +178,7 @@ class ComposeActivity : var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT @VisibleForTesting - val viewModel: ComposeViewModel by viewModels( - extrasProducer = { - defaultViewModelCreationExtras.withCreationCallback { factory -> - factory.create( - intent.pachliAccountId, - ComposeActivityIntent.getComposeOptions(intent), - ) - } - }, - ) + val viewModel: ComposeViewModel by viewModels() private val binding by viewBinding(ActivityComposeBinding::inflate) @@ -193,6 +190,8 @@ class ComposeActivity : @Inject lateinit var languageIdentifierFactory: LanguageIdentifier.Factory + private val draftId by unsafeLazy { ComposeActivityIntent.getComposeOptions(intent).draftId } + private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> // photoUploadUri should never be null at this point, but don't crash if it is. val uploadUri = photoUploadUri ?: return@registerForActivityResult @@ -216,17 +215,17 @@ class ComposeActivity : viewModel.cropImageItemOld?.let { itemOld -> val size = getMediaSize(contentResolver, uriNew) - lifecycleScope.launch { - viewModel.addMediaToQueue( - itemOld.type, - uriNew, - size, - itemOld.description, - // Intentionally reset focus when cropping - null, - itemOld, - ) - } + viewModel.accept( + FallibleUiAction.AttachMedia( + type = itemOld.type, + uri = uriNew, + mediaSize = size, + description = itemOld.description, + // Reset focus when cropping. + focus = null, + replaceItemId = itemOld.localId, + ), + ) } } else if (result == CropImage.CancelledResult) { Timber.w("Edit image cancelled by user") @@ -255,7 +254,7 @@ class ComposeActivity : return } - handleCloseButton() + confirmAndFinish() } } @@ -276,84 +275,94 @@ class ComposeActivity : val composeOptions = ComposeActivityIntent.getComposeOptions(intent) - binding.replyLoadingErrorRetry.setOnClickListener { viewModel.reloadReply() } + binding.replyLoadingErrorRetry.setOnClickListener { viewModel.accept(FallibleUiAction.LoadInReplyTo) } lifecycleScope.launch { viewModel.inReplyTo.collect(::bindInReplyTo) } - lifecycleScope.launch { - viewModel.accountFlow.take(1).collect { account -> - setupAvatar(account.entity) - - if (viewModel.displaySelfUsername) { - binding.composeUsernameView.text = getString( - R.string.compose_active_account_description, - account.entity.fullName, - ) - binding.composeUsernameView.show() - } else { - binding.composeUsernameView.hide() + val mediaAdapter = MediaPreviewAdapter( + glide = glide, + onDescriptionChanged = this@ComposeActivity::onUpdateDescription, + onEditFocus = { item -> + makeFocusDialog(item.focus, item.uri) { newFocus -> + viewModel.updateFocus(item.localId, newFocus) } + }, + onEditImage = this@ComposeActivity::editImageInQueue, + onRemoveMedia = this@ComposeActivity::removeMediaFromQueue, + ) - viewModel.setup(account) + binding.composeMediaPreviewBar.layoutManager = + LinearLayoutManager(this@ComposeActivity, LinearLayoutManager.VERTICAL, false) + binding.composeMediaPreviewBar.adapter = mediaAdapter + binding.composeMediaPreviewBar.itemAnimator = null - setupLanguageSpinner(getInitialLanguages(composeOptions?.language, account.entity)) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.uiResult.collect(::bindUiResult) } + + launch { + viewModel.initialUiState.collect { initial -> + val account = initial.account + setupAvatar(account.entity) + + launch { + viewModel.displaySelfUsername.collect { + if (it) { + binding.composeUsernameView.text = getString( + R.string.compose_active_account_description, + account.entity.fullName, + ) + binding.composeUsernameView.show() + } else { + binding.composeUsernameView.hide() + } + } + } - setupButtons(account.id) + mediaAdapter.setMediaDescriptionLimit(account.instanceInfo.maxMediaDescriptionChars) - if (savedInstanceState != null) { - setupComposeField(null, composeOptions) - } else { - setupComposeField(viewModel.initialContent, composeOptions) - } + setupButtons(account) - val mediaAdapter = MediaPreviewAdapter( - glide = glide, - descriptionLimit = account.instanceInfo.maxMediaDescriptionChars, - onDescriptionChanged = this@ComposeActivity::onUpdateDescription, - onEditFocus = { item -> - makeFocusDialog(item.focus, item.uri) { newFocus -> - viewModel.updateFocus(item.localId, newFocus) + if (savedInstanceState != null) { + setupComposeField(null, composeOptions) + } else { + setupComposeField(initial.content, composeOptions) } - }, - onEditImage = this@ComposeActivity::editImageInQueue, - onRemoveMedia = this@ComposeActivity::removeMediaFromQueue, - ) - subscribeToUpdates(mediaAdapter) + subscribeToUpdates(mediaAdapter) - binding.composeMediaPreviewBar.layoutManager = - LinearLayoutManager(this@ComposeActivity, LinearLayoutManager.VERTICAL, false) - binding.composeMediaPreviewBar.adapter = mediaAdapter - binding.composeMediaPreviewBar.itemAnimator = null + bindContentWarning(initial.contentWarning) + setupPollView(account.instanceInfo) + applyShareIntent(intent, savedInstanceState) - composeOptions?.scheduledAt?.let { - binding.composeScheduleView.setDateTime(it) - } - - bindContentWarning(composeOptions?.contentWarning) - setupPollView() - applyShareIntent(intent, savedInstanceState) + /* Finally, update state with data from saved instance state. */ + savedInstanceState?.let { + BundleCompat.getSerializable(it, KEY_VISIBILITY, Status.Visibility::class.java)?.let { + viewModel.onStatusVisibilityChanged(it) + } - /* Finally, overwrite state with data from saved instance state. */ - savedInstanceState?.let { - (it.getSerializable(KEY_VISIBILITY) as Status.Visibility).apply { - setStatusVisibility(this) - } + it.getBoolean(KEY_CONTENT_WARNING_VISIBLE).apply { + viewModel.showContentWarningChanged(this) + } - it.getBoolean(KEY_CONTENT_WARNING_VISIBLE).apply { - viewModel.showContentWarningChanged(this) - } + BundleCompat.getSerializable(it, KEY_SCHEDULED_TIME, Date::class.java)?.let { + viewModel.updateScheduledAt(it) + } + } - (it.getSerializable(KEY_SCHEDULED_TIME) as? Date)?.let { time -> - viewModel.updateScheduledAt(time) + binding.composeEditField.post { + binding.composeEditField.requestFocus() + } } } - binding.composeEditField.post { - binding.composeEditField.requestFocus() + launch { + viewModel.languages.collect(::setupLanguageSpinner) } } } + + viewModel.setup(intent.pachliAccountId, composeOptions) } /** @@ -406,6 +415,35 @@ class ComposeActivity : } } + private fun bindUiResult(uiResult: Result) { + // Show errors from the view model as snack bars. + // + // Errors are shown: + // - Indefinitely, so the user has a chance to read and understand + // the message + // - With a max of 5 text lines, to allow space for longer errors. + // E.g., on a typical device, an error message like "Bookmarking + // post failed: Unable to resolve host 'mastodon.social': No + // address associated with hostname" is 3 lines. + // - With a "Retry" option if the error included a UiAction to retry. + uiResult.onFailure { uiError -> + val message = uiError.fmt(this) + snackbar?.dismiss() + try { + Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE).apply { + uiError.action.let { uiAction -> setAction(app.pachli.core.ui.R.string.action_retry) { viewModel.accept(uiAction) } } + show() + snackbar = this + } + } catch (_: IllegalArgumentException) { + // On rare occasions this code is running before the fragment's + // view is connected to the parent. This causes Snackbar.make() + // to crash. See https://issuetracker.google.com/issues/228215869. + // For now, swallow the exception. + } + } + } + /** * Binds the [InReplyTo] data to the UI. * @@ -494,8 +532,7 @@ class ComposeActivity : binding.statusAvatar.setPaddingRelative(0, 0, 0, 0) if (viewModel.statusDisplayOptions.value.showBotOverlay && inReplyTo.isBot) { binding.statusAvatarInset.visibility = View.VISIBLE - glide.load(DR.drawable.bot_badge) - .into(binding.statusAvatarInset) + glide.load(DR.drawable.bot_badge).into(binding.statusAvatarInset) } else { binding.statusAvatarInset.visibility = View.GONE } @@ -569,15 +606,14 @@ class ComposeActivity : private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { lifecycleScope.launch { - viewModel.instanceInfo.collect { instanceData -> + viewModel.pachliAccountFlow.map { it.instanceInfo }.distinctUntilChanged().collect { instanceData -> maximumTootCharacters = instanceData.maxChars maxUploadMediaNumber = instanceData.maxMediaAttachments - updateVisibleCharactersLeft(viewModel.statusLength.value) } } lifecycleScope.launch { - viewModel.statusLength.collect { updateVisibleCharactersLeft(it) } + viewModel.statusLength.collect(::updateVisibleCharactersLeft) } lifecycleScope.launch { @@ -604,7 +640,10 @@ class ComposeActivity : mediaAdapter.submitList(media) binding.composeMediaPreviewBar.visible(media.isNotEmpty()) - updateMarkSensitiveButton(viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value) + updateMarkSensitiveButton( + viewModel.markMediaAsSensitive.value, + viewModel.showContentWarning.value, + ) } } @@ -661,10 +700,10 @@ class ComposeActivity : */ private fun updateOnBackPressedCallbackState(confirmationKind: ConfirmationKind, bottomSheetStates: List) { onBackPressedCallback.isEnabled = confirmationKind != ConfirmationKind.NONE || - bottomSheetStates.any { it != BottomSheetBehavior.STATE_HIDDEN } + bottomSheetStates.any { it == BottomSheetBehavior.STATE_EXPANDED } } - private fun setupButtons(pachliAccountId: Long) { + private fun setupButtons(pachliAccount: PachliAccount) { binding.composeOptionsBottomSheet.listener = this visibilityBehavior = BottomSheetBehavior.from(binding.composeOptionsBottomSheet) @@ -686,7 +725,7 @@ class ComposeActivity : enableButton(binding.composeEmojiButton, clickable = false, colorActive = false) // Setup the interface buttons. - binding.composeTootButton.setOnClickListener { onSendClick(pachliAccountId) } + binding.composeTootButton.setOnClickListener { onSendClick(pachliAccount.id) } binding.composeAddAttachmentButton.setOnClickListener { onAddAttachmentClick() } binding.composeChangeVisibilityButton.setOnClickListener { onChangeVisibilityClick() } binding.composeContentWarningButton.setOnClickListener { onContentWarningChanged() } @@ -711,7 +750,7 @@ class ComposeActivity : binding.actionPhotoTake.setOnClickListener { initiateCameraApp() } binding.actionAddMedia.setOnClickListener { onAddMediaClick() } - binding.addPollTextActionTextView.setOnClickListener { onAddPollClick() } + binding.addPollTextActionTextView.setOnClickListener { onAddPollClick(pachliAccount.instanceInfo) } onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } @@ -850,14 +889,6 @@ class ComposeActivity : super.onSaveInstanceState(outState) } - private fun displayPermamentMessage(message: String) { - val bar = Snackbar.make(binding.activityCompose, message, Snackbar.LENGTH_INDEFINITE) - // necessary so snackbar is shown over everything - bar.view.elevation = resources.getDimension(DR.dimen.compose_activity_snackbar_elevation) - bar.setAnchorView(R.id.composeBottomBar) - bar.show() - } - /** Displays a [Snackbar] showing [message], anchored to the bottom bar. */ private fun displayTransientMessage(message: String) { val bar = Snackbar.make(binding.activityCompose, message, Snackbar.LENGTH_LONG) @@ -881,6 +912,7 @@ class ComposeActivity : this.viewModel.toggleMarkSensitive() } + // TODO: Should use a color state list. private fun updateMarkSensitiveButton(markMediaSensitive: Boolean, contentWarningShown: Boolean) = with(binding.composeMarkSensitiveButton) { if (viewModel.media.value.isEmpty()) { hide() @@ -915,6 +947,7 @@ class ComposeActivity : } } + // TODO: Should use a color state list. private fun updateScheduleButton() { if (viewModel.editing) { // Can't reschedule a published status @@ -1082,21 +1115,20 @@ class ComposeActivity : /** * Handles clicking the "Add poll" button in the "Add attachment" menu. */ - private fun onAddPollClick() = lifecycleScope.launch { + private fun onAddPollClick(instanceInfo: InstanceInfo) = lifecycleScope.launch { addAttachmentBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - val instanceParams = viewModel.instanceInfo.value showAddPollDialog( context = this@ComposeActivity, poll = viewModel.poll.value, - maxOptionCount = instanceParams.pollMaxOptions, - maxOptionLength = instanceParams.pollMaxLength, - minDuration = instanceParams.pollMinDuration, - maxDuration = instanceParams.pollMaxDuration, + maxOptionCount = instanceInfo.pollMaxOptions, + maxOptionLength = instanceInfo.pollMaxLength, + minDuration = instanceInfo.pollMinDuration, + maxDuration = instanceInfo.pollMaxDuration, onUpdatePoll = viewModel::onPollChanged, ) } - private fun setupPollView() { + private fun setupPollView(instanceInfo: InstanceInfo) { binding.pollPreview.setOnClickListener { val popup = PopupMenu(this, binding.pollPreview) val editId = 1 @@ -1105,7 +1137,7 @@ class ComposeActivity : popup.menu.add(0, removeId, 0, R.string.action_remove) popup.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { - editId -> onAddPollClick() + editId -> onAddPollClick(instanceInfo) removeId -> removePoll() } true @@ -1124,10 +1156,6 @@ class ComposeActivity : visibilityBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } - @VisibleForTesting - val selectedLanguage: String? - get() = viewModel.language - private fun updateVisibleCharactersLeft(textLength: Int) { val remainingLength = maximumTootCharacters - textLength binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength) @@ -1173,7 +1201,7 @@ class ComposeActivity : // See https://github.com/mastodon/mastodon/issues/23541 // Null check. Shouldn't be necessary - val currentLang = viewModel.language ?: return + val currentLang = viewModel.language.value ?: return // Try and identify the language the status is written in. Limit to the // first three possibilities. Don't show errors to the user, just bail, @@ -1209,7 +1237,7 @@ class ComposeActivity : val detectedLocale = localeList[detectedLangTruncatedTag] ?: return val detectedDisplayLang = detectedLocale.displayLanguage - val currentDisplayLang = localeList[viewModel.language]?.displayLanguage ?: return + val currentDisplayLang = localeList[currentLang]?.displayLanguage ?: return // Otherwise, show the dialog. val dialog = AlertDialog.Builder(this@ComposeActivity) @@ -1258,17 +1286,14 @@ class ComposeActivity : private fun sendStatus(pachliAccountId: Long) { enableButtons(false, viewModel.editing) val contentText = binding.composeEditField.text.toString() - var spoilerText = "" - if (viewModel.showContentWarning.value) { - spoilerText = binding.composeContentWarningField.text.toString() - } + val spoilerText = viewModel.effectiveContentWarning.replayCache.firstOrNull().orEmpty() val statusLength = viewModel.statusLength.value if ((statusLength <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { binding.composeEditField.error = getString(R.string.error_empty) enableButtons(true, viewModel.editing) } else if (statusLength <= maximumTootCharacters) { lifecycleScope.launch { - viewModel.sendStatus(contentText, spoilerText, pachliAccountId) + viewModel.sendStatus(pachliAccountId, draftId, contentText, spoilerText) deleteDraftAndFinish() } } else { @@ -1320,6 +1345,8 @@ class ComposeActivity : private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { button.isEnabled = clickable + // TODO: This should use button.isActivated, and the button should have a color + // state list. if (colorActive) { setDrawableTint(this, button.drawable, android.R.attr.textColorTertiary) } else { @@ -1365,16 +1392,24 @@ class ComposeActivity : viewModel.removeMediaFromQueue(item) } + // Problem here. + // + // This can be called before the ViewModel has the account info, causing a + // "lateinit property pachliAccount has not been initialized" crash. + // + // Send this as a UiAction, with an associated UiError that can be + // displayed. + // + // The viewmodel can combine the uiaction with pachliaccountflow, + // and only perform the add when there's an account + private fun pickMedia(uri: Uri, description: String? = null) { - lifecycleScope.launch { - viewModel.pickMedia(uri, description).onFailure { - val message = getString( - R.string.error_pick_media_fmt, - it.fmt(this@ComposeActivity), - ) - displayPermamentMessage(message) - } - } + viewModel.accept( + FallibleUiAction.PickMedia( + uri = uri, + description = description, + ), + ) } /** @@ -1403,7 +1438,7 @@ class ComposeActivity : override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { - handleCloseButton() + confirmAndFinish() return true } @@ -1429,7 +1464,11 @@ class ComposeActivity : return super.onKeyDown(keyCode, event) } - private fun handleCloseButton() { + /** + * Finish the activity, optionally displaying a confirmation dialog to the + * user first. + */ + private fun confirmAndFinish() { val contentText = binding.composeEditField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString() when (viewModel.closeConfirmationKind.value) { @@ -1517,7 +1556,7 @@ class ComposeActivity : return AlertDialog.Builder(this) .setMessage(R.string.compose_delete_draft) .setPositiveButton(R.string.action_delete) { _, _ -> - viewModel.deleteDraft() + viewModel.deleteDraft(draftId) viewModel.stopUploads() finish() } @@ -1527,7 +1566,8 @@ class ComposeActivity : } private fun deleteDraftAndFinish() { - viewModel.deleteDraft() + viewModel.deleteDraft(draftId) + viewModel.stopUploads() finish() } @@ -1544,7 +1584,7 @@ class ComposeActivity : } else { null } - viewModel.saveDraft(contentText, contentWarning) + viewModel.saveDraft(intent.pachliAccountId, draftId, contentText, contentWarning) dialog?.cancel() finish() } diff --git a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt index a26098c8d7..af4e08de91 100644 --- a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt @@ -16,71 +16,84 @@ package app.pachli.components.compose -import android.content.ContentResolver import android.net.Uri import android.text.Editable import android.text.Spanned +import android.text.SpannedString import android.text.style.URLSpan -import androidx.annotation.StringRes -import androidx.annotation.VisibleForTesting import androidx.core.net.toUri +import androidx.core.text.toSpanned import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.pachli.R import app.pachli.components.compose.ComposeActivity.QueuedMedia import app.pachli.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult +import app.pachli.components.compose.UiError.PickMediaError import app.pachli.components.compose.UploadState.Uploaded import app.pachli.components.drafts.DraftHelper import app.pachli.components.search.SearchType import app.pachli.core.common.PachliError +import app.pachli.core.common.extensions.dirtyIf import app.pachli.core.common.extensions.stateFlow import app.pachli.core.common.string.mastodonLength import app.pachli.core.common.string.randomAlphanumericString import app.pachli.core.data.repository.AccountManager -import app.pachli.core.data.repository.InstanceInfoRepository import app.pachli.core.data.repository.Loadable import app.pachli.core.data.repository.PachliAccount import app.pachli.core.data.repository.ServerRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.database.model.AccountEntity +import app.pachli.core.model.InstanceInfo import app.pachli.core.model.ServerOperation import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions.ComposeKind import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions.InReplyTo import app.pachli.core.network.model.Attachment +import app.pachli.core.network.model.Emoji import app.pachli.core.network.model.NewPoll import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.preferences.ShowSelfUsername import app.pachli.core.ui.MentionSpan import app.pachli.service.MediaToSend import app.pachli.service.ServiceClient import app.pachli.service.StatusToSend +import app.pachli.util.getInitialLanguages +import app.pachli.util.getLocaleList +import app.pachli.util.modernLanguageCode import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import com.github.michaelbull.result.get -import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.mapBoth import com.github.michaelbull.result.mapEither import com.github.michaelbull.result.mapError -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.unwrap import dagger.hilt.android.lifecycle.HiltViewModel import io.github.z4kn4fein.semver.constraints.toConstraint import java.util.Date +import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -88,136 +101,273 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -internal sealed class UiError( - @StringRes override val resourceId: Int = -1, - override val formatArgs: Array? = null, - override val cause: PachliError? = null, -) : PachliError { +// TODO: +// +// - initial content not restored +// x is a poll in a draft restored? + +internal data class UiState( + // Server limits rather than UiState? Change very infrequently + val maxPostChars: Int, + val maxMediaAttachments: Int, + + // Like current effectiveContentWarning + val contentWarning: String, + // Like current content + val content: Editable, + + // Like initialContent? + val initialContent: String?, + + val displaySelfUsername: Boolean, +) + +internal data class InitialUiState( + val account: PachliAccount, +// val displaySelfUsername: Boolean, +// val maxMediaDescriptionLimit: Int, + /** The initial content for this status, before any edits */ + val content: String, + val contentWarning: String, + val scheduledAt: Date? = null, +) { + companion object { + fun from(composeOptions: ComposeOptions) { + } + } +} + +internal sealed interface UiAction + +// updateFocus +// showContentWarningChanged +// updateScheduledAt +// setup +// onContentWarningChanged +// onContentChanged +// onLanguageChanged +// toggleMarkSensitive +// onPollChanged +// onVisibilityChanged +// removeMediaFromQueue +// stopUploads +// shouldShowSaveDraftDialog -- should be a flow (maybe) +// updateDescription +// +// sendStatus +// - Should this use all the state in the viewModel instead of taking params? + +// TODO +// - A flow that tracks whether or not the post can be sent (Bool). This replaces +// the check in ComposeActivity.sendStatus(), and is collected to determine whether +// the toot button is active (note: poll still requires non-empty content) + +internal sealed interface FallibleUiAction : UiAction { + data object LoadInReplyTo : FallibleUiAction + data class PickMedia( + val uri: Uri, + val description: String? = null, + val focus: Attachment.Focus? = null, + ) : FallibleUiAction + + /** + * Attaches media identified by [uri] to this post. + * + * @param replaceItemId [ID][QueuedMedia.localId] of an existing + * attachment that should be replaced. Null if this media should + * not replace an existing attachment. + */ + data class AttachMedia( + val type: QueuedMedia.Type, + val uri: Uri, + val mediaSize: Long, + val description: String? = null, + val focus: Attachment.Focus? = null, + val replaceItemId: Int? = null, + ) : FallibleUiAction +} + +sealed interface UiSuccess + +internal sealed interface UiError : PachliError { + val action: UiAction + /** Error occurred loading the status this is a reply to. */ - data class LoadInReplyToError(override val cause: PachliError) : UiError( - R.string.ui_error_reload_reply_fmt, - ) + data class LoadInReplyToError(override val cause: PachliError) : UiError { + override val resourceId = R.string.ui_error_reload_reply_fmt + override val formatArgs = null + override val action = FallibleUiAction.LoadInReplyTo + } + + sealed interface PickMediaError : UiError { + data class PrepareMediaError( + override val cause: MediaUploaderError.PrepareMediaError, + override val action: UiAction, + ) : PickMediaError, MediaUploaderError.PrepareMediaError by cause + + /** + * User is trying to add an image to a post that already has a video + * attachment, or vice-versa. + */ + data class MixedMediaTypesError(override val action: UiAction) : PickMediaError { + override val resourceId = R.string.error_media_upload_image_or_video + override val formatArgs = null + override val cause = null + } + } } -@HiltViewModel(assistedFactory = ComposeViewModel.Factory::class) -class ComposeViewModel @AssistedInject constructor( - @Assisted private val pachliAccountId: Long, - @Assisted private val composeOptions: ComposeOptions?, +@HiltViewModel +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, serverRepository: ServerRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, private val sharedPreferencesRepository: SharedPreferencesRepository, ) : ViewModel() { - /** The account being used to compose the status. */ - val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId) - .filterNotNull() - .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) + val pachliAccountId = MutableSharedFlow(replay = 1) + + val pachliAccountFlow = pachliAccountId.distinctUntilChanged().flatMapLatest { + accountManager.getPachliAccountFlow(it).filterNotNull() + }.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) - private lateinit var pachliAccount: PachliAccount + val composeOptions = MutableStateFlow(ComposeOptions()) - /** The current content */ - private var content: Editable = Editable.Factory.getInstance().newEditable("") + /** Flow of user actions received from the UI */ + private val uiAction = MutableSharedFlow(replay = 1) + + private val _uiResult = Channel>() + internal val uiResult = _uiResult.receiveAsFlow() + + /** Accept UI actions in to actionStateFlow */ + internal val accept: (UiAction) -> Unit = { action -> + viewModelScope.launch { uiAction.emit(action) } + } /** - * The effective content warning. Either the real content warning, or the empty string - * if the content warning has been hidden + * Triggers a reload of the status being replied to every time a value is emitted + * into this flow. */ - private val effectiveContentWarning - get() = if (showContentWarning.value) contentWarning else "" + private val reloadReply = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } - private val loadReply = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } + private val _initialUiState = MutableSharedFlow() + internal val initialUiState = _initialUiState.asSharedFlow() + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1) /** * Flow of data about the in-reply-to status for this post. * - * - Ok(null) - this is not a reply - * - Ok(InReplyTo.Status) - this is a reply, with the status being replied to - * - Err() - error occurred fetching the parent + * Loadable.Loaded contents are: + * - Ok(null) - this is not a reply. + * - Ok(InReplyTo.Status) - this is a reply, with the status being replied to. + * + * - Err() - error occurred fetching the status being replied to. */ - internal val inReplyTo = stateFlow(viewModelScope, Ok(Loadable.Loaded(null))) { - loadReply.flatMapLatest { - flow { - when (val i = composeOptions?.inReplyTo) { - is InReplyTo.Id -> { - emit(Ok(Loadable.Loading)) - api.status(i.statusId).mapEither( - { Loadable.Loaded(InReplyTo.Status.from(it.body)) }, - { UiError.LoadInReplyToError(it) }, - ) - } - is InReplyTo.Status -> Ok(Loadable.Loaded(i)) - null -> Ok(Loadable.Loaded(null)) - }.also { emit(it) } - } - }.flowWhileShared(SharingStarted.WhileSubscribed(5000)) - } + // Reload the reply when either the status being replied to changes or the user + // explicitly triggers a reload. + internal val inReplyTo = stateFlow(viewModelScope, Ok(Loadable.Loading)) { + reloadReply.combine(composeOptions.map { it.inReplyTo }.distinctUntilChanged()) { _, inReplyTo -> inReplyTo } + .flatMapLatest { inReplyTo -> + flow { + when (inReplyTo) { + is InReplyTo.Id -> { + emit(Ok(Loadable.Loading)) + api.status(inReplyTo.statusId) + .mapEither( + { Loadable.Loaded(InReplyTo.Status.from(it.body)) }, + { UiError.LoadInReplyToError(it) }, + ) + } - /** Triggers a reload of the status being replied to. */ - internal fun reloadReply() = viewModelScope.launch { loadReply.emit(Unit) } + is InReplyTo.Status -> Ok(Loadable.Loaded(inReplyTo)) + null -> Ok(Loadable.Loaded(null)) + }.also { emit(it) } + } + }.flowWhileShared(SharingStarted.WhileSubscribed(5000)) + } /** The initial content for this status, before any edits */ - internal var initialContent: String = composeOptions?.content.orEmpty() + private val initialContent = MutableSharedFlow(replay = 1) - /** The initial content warning for this status, before any edits */ - private val initialContentWarning: String = composeOptions?.contentWarning.orEmpty() + /** The current language for this status. */ + private val _language = MutableStateFlow(null) + val language: StateFlow = _language.asStateFlow() - /** The current content warning */ - private var contentWarning: String = initialContentWarning + /** If editing a draft then the ID of the draft, otherwise 0 */ +// private val draftId = composeOptions.map { it.draftId } +// .onEach { Timber.d("draftId: $it") } +// .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) + + /** If editing a status, the original ID of the status. */ + private val originalStatusId = composeOptions.map { it.statusId }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + null, + ) - /** The initial language for this status, before any changes */ - private val initialLanguage: String? = composeOptions?.language + val instanceInfo = MutableStateFlow(InstanceInfo()) + val emojis = MutableStateFlow(emptyList()) - /** The current language for this status. */ - internal var language: String? = initialLanguage + private val _markMediaAsSensitive = MutableStateFlow(false) + val markMediaAsSensitive = _markMediaAsSensitive.asStateFlow() - /** If editing a draft then the ID of the draft, otherwise 0 */ - private val draftId = composeOptions?.draftId ?: 0 - private val scheduledTootId: String? = composeOptions?.scheduledTootId - private val originalStatusId: String? = composeOptions?.statusId - private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN + /** Flow of changes to statusDisplayOptions, for use by the UI */ + val statusDisplayOptions = statusDisplayOptionsRepository.flow - private var contentWarningStateChanged: Boolean = false - private val modifiedInitialState: Boolean = composeOptions?.modifiedInitialState == true - private var scheduledTimeChanged: Boolean = false + // -- Flows for content. - val instanceInfo = instanceInfoRepo.instanceInfo + // This is Spanned rather than Editable because if the value is updated + // by ComposeActivity + private val content = MutableStateFlow(SpannedString("")) - val emojis = instanceInfoRepo.emojis + // -- Flows for content warning. + private val contentWarning = MutableStateFlow("") - private val _markMediaAsSensitive: MutableStateFlow = MutableStateFlow(composeOptions?.sensitive) - val markMediaAsSensitive = accountFlow.combine(_markMediaAsSensitive) { account, sens -> - sens ?: account.entity.defaultMediaSensitivity - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + // TODO: This is probably wrong, this should be Flow toggled by the user. +// val showContentWarning = fContentWarning.map { it.isNotBlank() } + private val _showContentWarning = MutableStateFlow(false) + val showContentWarning = _showContentWarning.asStateFlow() - /** Flow of changes to statusDisplayOptions, for use by the UI */ - val statusDisplayOptions = statusDisplayOptionsRepository.flow + /** + * The effective content warning. Either the real content warning, or the empty string + * if the content warning has been hidden + */ + val effectiveContentWarning = combine(showContentWarning, contentWarning) { show, cw -> + Timber.d("Evaluating effectiveContentWarning") + Timber.d(" show: $show") + Timber.d(" cw: $cw") + if (show) cw else "" + }.onEach { Timber.d("fEffectiveContentWarning: $it") } + // .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) + + /** Length of the status. */ + val statusLength = combine(content, contentWarning, instanceInfo) { content, contentWarning, instanceInfo -> + statusLength(content, contentWarning, instanceInfo.charactersReservedPerUrl) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) private val _statusVisibility: MutableStateFlow = MutableStateFlow(Status.Visibility.UNKNOWN) val statusVisibility = _statusVisibility.asStateFlow() - private val _showContentWarning: MutableStateFlow = MutableStateFlow(false) - val showContentWarning = _showContentWarning.asStateFlow() + private val _poll: MutableStateFlow = MutableStateFlow(null) val poll = _poll.asStateFlow() - private val _scheduledAt: MutableStateFlow = MutableStateFlow(composeOptions?.scheduledAt) + + private val _scheduledAt = MutableStateFlow(null) val scheduledAt = _scheduledAt.asStateFlow() private val _media: MutableStateFlow> = MutableStateFlow(emptyList()) val media = _media.asStateFlow() - private val _closeConfirmationKind = MutableStateFlow(ConfirmationKind.NONE) - val closeConfirmationKind = _closeConfirmationKind.asStateFlow() - private val _statusLength = MutableStateFlow(0) - val statusLength = _statusLength.asStateFlow() + + private val _languages = MutableStateFlow(emptyList()) + val languages = _languages.asStateFlow() /** Flow of whether or not the server can schedule posts. */ val serverCanSchedule = serverRepository.flow.map { it.get()?.can(ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED, ">= 1.0.0".toConstraint()) == true - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + }.distinctUntilChanged() /** * True if the post's language should be checked before posting. @@ -230,99 +380,294 @@ class ComposeViewModel @AssistedInject constructor( sharedPreferencesRepository.confirmStatusLanguage = value } - private val composeKind = composeOptions?.kind ?: ComposeKind.NEW + private val composeKind = composeOptions.map { it.kind ?: ComposeKind.NEW } + .onEach { Timber.d("new composeKind: $it") } // Used in ComposeActivity to pass state to result function when cropImage contract inflight var cropImageItemOld: QueuedMedia? = null // TODO: Copied from MainViewModel. Probably belongs back in AccountManager - val displaySelfUsername: Boolean - get() = when (sharedPreferencesRepository.showSelfUsername) { - ShowSelfUsername.ALWAYS -> true - ShowSelfUsername.DISAMBIGUATE -> accountManager.accountsFlow.value.size > 1 - ShowSelfUsername.NEVER -> false + + /** True if the account's username should be shown, false otherwise. */ + val displaySelfUsername = sharedPreferencesRepository.changes.filter { it == PrefKeys.SHOW_SELF_USERNAME }.onStart { emit(null) } + .combine(accountManager.accountsFlow) { _, accounts -> + when (sharedPreferencesRepository.showSelfUsername) { + ShowSelfUsername.ALWAYS -> true + ShowSelfUsername.DISAMBIGUATE -> accounts.size > 1 + ShowSelfUsername.NEVER -> false + } } private var setupComplete = false - /** Errors preparing media for upload. */ - sealed interface PickMediaError : PachliError { - @JvmInline - value class PrepareMediaError(val error: MediaUploaderError.PrepareMediaError) : PickMediaError, MediaUploaderError.PrepareMediaError by error + val dirtyContent = content + .dirtyIf { first, last -> first.toString() != last.toString() } + .onEach { Timber.d("dirtyContent?: $it") } - /** - * User is trying to add an image to a post that already has a video - * attachment, or vice-versa. - */ - data object MixedMediaTypesError : PickMediaError { - override val resourceId = R.string.error_media_upload_image_or_video - override val formatArgs = null - override val cause = null + val dirtyContentWarning = effectiveContentWarning + .dirtyIf { first, last -> first != last } + .onEach { Timber.d("dirtyContentWarning: $it") } + + // Note: This will get called multiple times when media is uploading as + // QueuedMedia includes the upload percentage, which will change. At the + // moment do nothing about this, but consider it for the future. + val dirtyMedia = media.dirtyIf { first, last -> + if (first.size != last.size) return@dirtyIf true + + first.zip(last) { initial, media -> + if (initial.description.orEmpty() != media.description.orEmpty()) return@dirtyIf true + if (initial.uri != media.uri) return@dirtyIf true + if (initial.focus != media.focus) return@dirtyIf true } + + return@dirtyIf false + }.onEach { Timber.d("dirtyMedia: $it") } + + val dirtyPoll = poll.dirtyIf { first, second -> first != second } + + val dirtyLanguage = language.dirtyIf { first, last -> first != last } + .onEach { Timber.d("dirtyLanguage: $it") } + + val dirtyVisibility = statusVisibility.dirtyIf { first, last -> first != last } + .onEach { Timber.d("dirtyVisibility: $it") } + + val dirtyScheduledAt = scheduledAt.dirtyIf { first, last -> first != last } + .onEach { Timber.d("dirtyScheduledAt: $it") } + + val dirtySensitive = markMediaAsSensitive.dirtyIf { first, last -> first != last } + .onEach { Timber.d("dirtySensitive: $it") } + + /** + * @return True if content of this status is "dirty", meaning one or more of the + * following have changed since the compose session started: + * - content + * - content warning + * - content warning visibility + * - media + * - polls + * - scheduled time to send + * - sensitivity + * - language + */ + val isDirty = combine( + dirtyContent, + dirtyContentWarning, + dirtyMedia, + dirtyPoll, + dirtyLanguage, + dirtyVisibility, + dirtyScheduledAt, + dirtySensitive, + // modifiedInitialState, whatever that's for + ) { flows -> flows.any { it == true } }.distinctUntilChanged() + .onEach { Timber.d("isDirty: $it") } + + init { + viewModelScope.launch { + pachliAccountFlow.collect { account -> + val composeOptions = composeOptions.value + + _scheduledAt.value = composeOptions.scheduledAt + + _showContentWarning.value = composeOptions.contentWarning.orEmpty().isNotEmpty() + contentWarning.value = composeOptions.contentWarning.orEmpty() + +// Timber.d("show content warning?: ${_showContentWarning.value}") +// Timber.d(" actual cw: ${fContentWarning.value}") + // Timber.d(" effect cw: ${fEffectiveContentWarning.value}") + +// pachliAccount = account + instanceInfo.value = account.instanceInfo + emojis.value = account.emojis + + // Fetch the list of languages from composeOptions (which may be null). Use + // this to build the list of locales in the correct order for this account. + // The initial language code is the first entry in this list, update the + // value in composeOptions and _language to be consistent. + _languages.value = getInitialLanguages(composeOptions.language, account.entity) + val locales = getLocaleList(languages.value) + val initialLanguageCode = locales.first().modernLanguageCode + this@ComposeViewModel.composeOptions.update { it.copy(language = initialLanguageCode) } + _language.value = initialLanguageCode + + // Set the visibility. + // + // Visible is set from (in-order): + // + // - Visibility in composeOptions (if present) + // - The more private of the visibility of the status being replied to (if this + // is a reply), or the user's default visibility. + // + // If we don't know the status' visibility (because we only have the ID) then + // fall back to the user's default visibility + val visibility = composeOptions.visibility ?: (composeOptions.inReplyTo as? InReplyTo.Status)?.visibility?.let { + account.entity.defaultPostPrivacy.coerceAtLeast(it) + } ?: account.entity.defaultPostPrivacy + _statusVisibility.value = visibility + + initialContent.emit( + composeOptions.mentionedUsernames?.let { mentionedUsernames -> + buildString { + mentionedUsernames.forEach { + append('@') + append(it) + append(' ') + } + } + } ?: composeOptions.content.orEmpty(), + ) + + val poll = composeOptions.poll + if (poll != null && + composeOptions.mediaAttachments.isNullOrEmpty() && + composeOptions.draftAttachments.isNullOrEmpty() + ) { + _poll.value = poll + } + + // Recreate the attachments. This is either: + // + // - Attachments from a draft, in which case they must be re-uploaded and + // attached. + // - Existing attachments, in which case use them as is. + val draftAttachments = composeOptions.draftAttachments + if (draftAttachments != null) { + draftAttachments.forEach { attachment -> + // Don't emit the action, call onPickMedia directly. This ensures + // `media` is updated **before** setting the value of `initialMedia` + // a few lines later. Otherwise the media is set after, `initialMedia` + // doesn't include it, and the media is treated as dirty. + onPickMedia( + account, + FallibleUiAction.PickMedia( + attachment.uri, + attachment.description, + attachment.focus, + ), + ) + } + } else { + composeOptions.mediaAttachments?.forEach { attachment -> + // when coming from redraft or ScheduledTootActivity + val mediaType = when (attachment.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(account.entity, attachment.id, mediaType, attachment.url.toUri(), attachment.description, attachment.meta?.focus) + } + } + + _initialUiState.emit( + InitialUiState( + account = account, + + scheduledAt = composeOptions.scheduledAt, + content = composeOptions.mentionedUsernames?.let { mentionedUsernames -> + buildString { + mentionedUsernames.forEach { + append('@') + append(it) + append(' ') + } + } + } ?: composeOptions.content.orEmpty(), + contentWarning = contentWarning.value, + + // TODO: + // initialVisibility + // languages + // poll + ), + ) + + launch { + uiAction.collect { onUiAction(account, it) } + } + } + } + } + + private suspend fun onUiAction(account: PachliAccount, uiAction: UiAction) { + val result = when (uiAction) { + is FallibleUiAction.AttachMedia -> onUpdateMedia(account, uiAction) + FallibleUiAction.LoadInReplyTo -> reloadReply.emit(Unit) + is FallibleUiAction.PickMedia -> onPickMedia(account, uiAction) + } + // TODO: Emit the result } /** * Copies selected media and adds to the upload queue. - * - * @param mediaUri [ContentResolver] URI for the file to copy - * @param description media description / caption - * @param focus focus, if relevant */ - suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result = withContext(Dispatchers.IO) { - val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.value) - .mapError { PickMediaError.PrepareMediaError(it) }.getOrElse { return@withContext Err(it) } - val mediaItems = media.value - if (type != QueuedMedia.Type.IMAGE && mediaItems.isNotEmpty() && mediaItems[0].type == QueuedMedia.Type.IMAGE) { - Err(PickMediaError.MixedMediaTypesError) - } else { - val queuedMedia = addMediaToQueue(type, uri, size, description, focus) - Ok(queuedMedia) + private suspend fun onPickMedia( + account: PachliAccount, + action: FallibleUiAction.PickMedia, + ): Result = withContext(Dispatchers.IO) { + val (type, uri, size) = mediaUploader.prepareMedia(action.uri, instanceInfo.value) + .mapError { PickMediaError.PrepareMediaError(it, action) } + .onFailure { return@withContext Err(it) }.unwrap() + + media.value.firstOrNull()?.let { firstItem -> + if (type != QueuedMedia.Type.IMAGE && firstItem.type == QueuedMedia.Type.IMAGE) { + _uiResult.send(Err(PickMediaError.MixedMediaTypesError(action))) + return@withContext Err(PickMediaError.MixedMediaTypesError(action)) + } } + + addMediaToQueue(account, type, uri, size) + return@withContext Ok(Unit) + } + + private suspend fun onUpdateMedia(account: PachliAccount, uiAction: FallibleUiAction.AttachMedia) { + addMediaToQueue( + account, + uiAction.type, + uiAction.uri, + uiAction.mediaSize, + uiAction.description, + uiAction.focus, + uiAction.replaceItemId, + ) } - fun addMediaToQueue( + internal fun addMediaToQueue( + account: PachliAccount, type: QueuedMedia.Type, uri: Uri, mediaSize: Long, description: String? = null, focus: Attachment.Focus? = null, - replaceItem: QueuedMedia? = null, + replaceItemId: Int? = null, ): QueuedMedia { - var stashMediaItem: QueuedMedia? = null + val mediaItem = QueuedMedia( + account = account.entity, + localId = mediaUploader.getNewLocalMediaId(), + uri = uri, + type = type, + mediaSize = mediaSize, + description = description, + focus = focus, + uploadState = Ok(UploadState.Uploading(percentage = 0)), + ) _media.update { mediaList -> - val mediaItem = QueuedMedia( - account = pachliAccount.entity, - localId = mediaUploader.getNewLocalMediaId(), - uri = uri, - type = type, - mediaSize = mediaSize, - description = description, - focus = focus, - uploadState = Ok(UploadState.Uploading(percentage = 0)), - ) - stashMediaItem = mediaItem - - if (replaceItem != null) { - mediaUploader.cancelUploadScope(replaceItem.localId) - mediaList.map { - if (it.localId == replaceItem.localId) mediaItem else it - } - } else { // Append - mediaList + mediaItem + replaceItemId?.let { + mediaUploader.cancelUploadScope(replaceItemId) + return@update mediaList.map { if (it.localId == replaceItemId) mediaItem else it } } + + return@update 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.value) - .collect { uploadResult -> - updateMediaItem(mediaItem.localId) { it.copy(uploadState = uploadResult) } + .collect { uploadState -> + updateMediaItem(mediaItem.localId) { it.copy(uploadState = uploadState) } } } - updateCloseConfirmation() return mediaItem } @@ -345,7 +690,6 @@ class ComposeViewModel @AssistedInject constructor( fun removeMediaFromQueue(item: QueuedMedia) { mediaUploader.cancelUploadScope(item.localId) _media.update { mediaList -> mediaList.filter { it.localId != item.localId } } - updateCloseConfirmation() } fun toggleMarkSensitive() { @@ -354,22 +698,23 @@ class ComposeViewModel @AssistedInject constructor( /** Call this when the status' primary content changes */ fun onContentChanged(newContent: Editable) { - content = newContent - updateStatusLength() - updateCloseConfirmation() + // The Editable received from the activity is always the same as the content + // it contains is mutable. Assigning it directly to .value would do nothing, + // as this is a stateflow where modifications that set the same value are + // ignored. So call .toSpanned() to ensure the new value is different from + // the previous value. + content.value = newContent.toSpanned() } /** Call this when the status' content warning changes */ fun onContentWarningChanged(newContentWarning: String) { - contentWarning = newContentWarning - updateStatusLength() - updateCloseConfirmation() + Timber.d("cw changed") + contentWarning.value = newContentWarning } /** Call this to attach or clear the status' poll */ fun onPollChanged(newPoll: NewPoll?) { _poll.value = newPoll - updateCloseConfirmation() } /** Call this to change the status' visibility */ @@ -379,83 +724,74 @@ class ComposeViewModel @AssistedInject constructor( /** Call this to change the status' language */ fun onLanguageChanged(newLanguage: String) { - language = newLanguage - updateCloseConfirmation() + _language.value = newLanguage } - @VisibleForTesting - fun updateStatusLength() { - _statusLength.value = statusLength(content, effectiveContentWarning, instanceInfo.value.charactersReservedPerUrl) - } + val closeConfirmationKind = combine(isDirty, composeKind, effectiveContentWarning) { dirty, composeKind, cw -> + Timber.d("Creating new closeConfirmationKind") + if (!dirty) return@combine ConfirmationKind.NONE - private fun updateCloseConfirmation() { - _closeConfirmationKind.value = if (isDirty()) { - when (composeKind) { - ComposeKind.NEW -> if (isEmpty(content, effectiveContentWarning)) { - ConfirmationKind.NONE - } else { - ConfirmationKind.SAVE_OR_DISCARD - } - ComposeKind.EDIT_DRAFT -> if (isEmpty(content, effectiveContentWarning)) { - 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 + return@combine when (composeKind) { + ComposeKind.NEW -> if (isEmpty(cw)) { + ConfirmationKind.NONE + } else { + ConfirmationKind.SAVE_OR_DISCARD } - } else { - ConfirmationKind.NONE + + ComposeKind.EDIT_DRAFT -> if (isEmpty(cw)) { + 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 } } + .onEach { Timber.d("New close confirmation: $it") } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ConfirmationKind.NONE) /** - * @return True if content of this status is "dirty", meaning one or more of the - * following have changed since the compose session started: content, - * content warning and content warning visibility, media, polls, or the - * scheduled time to send. + * True if the status being composed is empty. This means: + * + * - Blank content (empty, or only whitespace) + * - Blank content warning + * - No media + * - No poll + * + * @param effectiveContentWarning */ - private fun isDirty(): Boolean { - val contentChanged = !content.contentEquals(initialContent) - - val contentWarningChanged = effectiveContentWarning != initialContentWarning - val mediaChanged = media.value.isNotEmpty() - val pollChanged = poll.value != null - val languageChanged = initialLanguage != language - - return modifiedInitialState || contentChanged || contentWarningChanged || mediaChanged || pollChanged || languageChanged || scheduledTimeChanged - } - - private fun isEmpty(content: CharSequence, contentWarning: CharSequence): Boolean { - return !modifiedInitialState && (content.isBlank() && contentWarning.isBlank() && media.value.isEmpty() && poll.value == null) + private fun isEmpty(effectiveContentWarning: CharSequence): Boolean { + return content.value.isBlank() && effectiveContentWarning.isBlank() && media.value.isEmpty() && poll.value == null } fun showContentWarningChanged(value: Boolean) { _showContentWarning.value = value - contentWarningStateChanged = true - updateStatusLength() } - fun deleteDraft() { + /** Deletes the draft identified by [draftId]. Does nothing if [draftId] is 0 */ + fun deleteDraft(draftId: Int) { + if (draftId == 0) return + viewModelScope.launch { - if (draftId != 0) { - draftHelper.deleteDraftAndAttachments(draftId) - } + draftHelper.deleteDraftAndAttachments(draftId) } } + /** Cancels all in-progress uploads. */ 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 + // 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) { + suspend fun saveDraft(pachliAccountId: Long, draftId: Int, content: String, contentWarning: String) { val mediaUris: MutableList = mutableListOf() val mediaDescriptions: MutableList = mutableListOf() val mediaFocus: MutableList = mutableListOf() @@ -468,7 +804,7 @@ class ComposeViewModel @AssistedInject constructor( draftHelper.saveDraft( draftId = draftId, pachliAccountId = pachliAccountId, - inReplyToId = composeOptions?.inReplyTo?.statusId, + inReplyToId = composeOptions.value.inReplyTo?.statusId, content = content, contentWarning = contentWarning, sensitive = markMediaAsSensitive.value, @@ -480,8 +816,8 @@ class ComposeViewModel @AssistedInject constructor( failedToSend = false, failedToSendAlert = false, scheduledAt = scheduledAt.value, - language = language, - statusId = originalStatusId, + language = language.value, + statusId = originalStatusId.value, ) } @@ -490,13 +826,17 @@ class ComposeViewModel @AssistedInject constructor( * Uses current state plus provided arguments. */ suspend fun sendStatus( + pachliAccountId: Long, + draftId: Int, content: String, spoilerText: String, - pachliAccountId: Long, ) { - if (!scheduledTootId.isNullOrEmpty()) { - api.deleteScheduledStatus(scheduledTootId!!) - } + // TODO: Should probably return Result here if this fails, surface failure to + // the user. +// scheduledPostId.value.takeIf { !it.isNullOrEmpty() }?.let { +// api.deleteScheduledStatus(it) +// } + composeOptions.value.scheduledTootId?.let { api.deleteScheduledStatus(it) } val attachedMedia = media.value.map { item -> MediaToSend( @@ -508,6 +848,7 @@ class ComposeViewModel @AssistedInject constructor( processed = item.uploadState.get() is Uploaded.Processed || item.uploadState.get() is Uploaded.Published, ) } + val tootToSend = StatusToSend( text = content, warningText = spoilerText, @@ -515,7 +856,7 @@ class ComposeViewModel @AssistedInject constructor( sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), media = attachedMedia, scheduledAt = scheduledAt.value, - inReplyToId = composeOptions?.inReplyTo?.statusId, + inReplyToId = composeOptions.value.inReplyTo?.statusId, poll = poll.value, replyingStatusContent = null, replyingStatusAuthorUsername = null, @@ -523,8 +864,8 @@ class ComposeViewModel @AssistedInject constructor( draftId = draftId, idempotencyKey = randomAlphanumericString(16), retries = 0, - language = language, - statusId = originalStatusId, + language = language.value, + statusId = originalStatusId.value, ) serviceClient.sendToot(tootToSend) @@ -593,82 +934,30 @@ class ComposeViewModel @AssistedInject constructor( } } - fun setup(account: PachliAccount) { - if (setupComplete) { - return - } - - pachliAccount = account - - val preferredVisibility = account.entity.defaultPostPrivacy - - val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN - startingVisibility = Status.Visibility.getOrUnknown( - preferredVisibility.ordinal.coerceAtLeast(replyVisibility.ordinal), - ) - - if (!contentWarningStateChanged) { - _showContentWarning.value = contentWarning.isNotBlank() - } - - // 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(account.entity, a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus) - } - } - - val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN - if (tootVisibility != Status.Visibility.UNKNOWN) { - 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(' ') - } - initialContent = builder.toString() - } - - val poll = composeOptions?.poll - if (poll != null && composeOptions?.mediaAttachments.isNullOrEmpty()) { - _poll.value = poll + fun setup(pachliAccountId: Long, composeOptions: ComposeOptions) { + Timber.d("setup()") + viewModelScope.launch { + this@ComposeViewModel.composeOptions.value = composeOptions + this@ComposeViewModel.pachliAccountId.emit(pachliAccountId) } - updateCloseConfirmation() setupComplete = true } fun updateScheduledAt(newScheduledAt: Date?) { - if (newScheduledAt != scheduledAt.value) { - scheduledTimeChanged = true - } - _scheduledAt.value = newScheduledAt - updateCloseConfirmation() } + /** + * True if editing a status that has already been posted. + * + * False otherwise (this includes editing a scheduled status that has not reached + * the scheduled time yet). + */ val editing: Boolean - get() = !originalStatusId.isNullOrEmpty() + get() = !originalStatusId.value.isNullOrEmpty() + /** How to confirm with the user when leaving [ComposeActivity]. */ enum class ConfirmationKind { /** No confirmation, finish */ NONE, @@ -735,16 +1024,4 @@ class ComposeViewModel @AssistedInject constructor( return length } } - - @AssistedFactory - interface Factory { - /** - * Creates [ComposeViewModel] with [pachliAccountId] as the active account and - * active [composeOptions]. - */ - fun create( - pachliAccountId: Long, - composeOptions: ComposeOptions?, - ): ComposeViewModel - } } diff --git a/app/src/main/java/app/pachli/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/app/pachli/components/compose/MediaPreviewAdapter.kt index 74afa7e096..1627b5a119 100644 --- a/app/src/main/java/app/pachli/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/app/pachli/components/compose/MediaPreviewAdapter.kt @@ -101,19 +101,20 @@ typealias OnRemoveMediaListener = (item: QueuedMedia) -> Unit */ class MediaPreviewAdapter( private val glide: RequestManager, - private val descriptionLimit: Int, private val onDescriptionChanged: OnDescriptionChangedListener, private val onEditFocus: OnEditFocusListener, private val onEditImage: OnEditImageListener, private val onRemoveMedia: OnRemoveMediaListener, ) : ListAdapter(QUEUED_MEDIA_DIFFER) { + private var descriptionLimit = 1500 + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AttachmentViewHolder { return AttachmentViewHolder( ItemComposeMediaAttachmentBinding.inflate(LayoutInflater.from(parent.context), parent, false), glide, - descriptionLimit, onDescriptionChanged = onDescriptionChanged, onMediaClick = ::showMediaPopup, + getDescriptionLimit = { return@AttachmentViewHolder descriptionLimit }, ) } @@ -125,6 +126,13 @@ class MediaPreviewAdapter( holder.bind(getItem(position), payloads) } + fun setMediaDescriptionLimit(limit: Int) { + if (limit != descriptionLimit) { + descriptionLimit = limit + notifyItemRangeChanged(0, itemCount, Payload.MEDIA_DESCRIPTION_LIMIT) + } + } + /** * Actions that can be performed on the attached media. * @@ -195,6 +203,9 @@ class MediaPreviewAdapter( /** [QueuedMedia.uploadState] changed */ UPLOAD_STATE, + + /** Media description limit changed. */ + MEDIA_DESCRIPTION_LIMIT, } private val QUEUED_MEDIA_DIFFER = object : DiffUtil.ItemCallback() { @@ -228,23 +239,23 @@ class MediaPreviewAdapter( * Displays media attachments using [ItemComposeMediaAttachmentBinding]. * * @param binding View binding for the UI. - * @param descriptionLimit Max characters for a media description. * @param onDescriptionChanged Called when the description is changed. * @param onMediaClick Called when the user clicks the media preview image. + * @param getDescriptionLimit Called to fetch the current media description limit. */ class AttachmentViewHolder( val binding: ItemComposeMediaAttachmentBinding, private val glide: RequestManager, - descriptionLimit: Int, private val onDescriptionChanged: OnDescriptionChangedListener, private val onMediaClick: (QueuedMedia, View) -> Unit, + private val getDescriptionLimit: () -> Int, ) : RecyclerView.ViewHolder(binding.root) { private val context = binding.root.context private lateinit var item: QueuedMedia init { - binding.descriptionLayout.counterMaxLength = descriptionLimit + binding.descriptionLayout.counterMaxLength = getDescriptionLimit() binding.description.doAfterTextChanged { it?.let { onDescriptionChanged(item, it.toString()) } } binding.media.setOnClickListener { onMediaClick(item, binding.media) } @@ -270,6 +281,7 @@ class AttachmentViewHolder( Payload.DESCRIPTION -> bindDescription(item.description) Payload.FOCUS -> bindFocus(item.focus) Payload.UPLOAD_STATE -> bindUploadState(item.uploadState) + Payload.MEDIA_DESCRIPTION_LIMIT -> binding.descriptionLayout.counterMaxLength = getDescriptionLimit() else -> bindAll(item) } } diff --git a/app/src/main/java/app/pachli/components/compose/view/ComposeOptionsView.kt b/app/src/main/java/app/pachli/components/compose/view/ComposeVisibilityView.kt similarity index 94% rename from app/src/main/java/app/pachli/components/compose/view/ComposeOptionsView.kt rename to app/src/main/java/app/pachli/components/compose/view/ComposeVisibilityView.kt index 52e5bb6976..a9bcbdbcae 100644 --- a/app/src/main/java/app/pachli/components/compose/view/ComposeOptionsView.kt +++ b/app/src/main/java/app/pachli/components/compose/view/ComposeVisibilityView.kt @@ -22,7 +22,7 @@ import android.widget.RadioGroup import app.pachli.R import app.pachli.core.network.model.Status -class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(context, attrs) { +class ComposeVisibilityView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(context, attrs) { var listener: ComposeVisibilityListener? = null diff --git a/app/src/main/java/app/pachli/components/drafts/DraftHelper.kt b/app/src/main/java/app/pachli/components/drafts/DraftHelper.kt index 1a9d766795..af6d1e5fda 100644 --- a/app/src/main/java/app/pachli/components/drafts/DraftHelper.kt +++ b/app/src/main/java/app/pachli/components/drafts/DraftHelper.kt @@ -66,7 +66,7 @@ class DraftHelper @Inject constructor( scheduledAt: Date?, language: String?, statusId: String?, - ) = withContext(Dispatchers.IO) { + ): Int = withContext(Dispatchers.IO) { val externalFilesDir = context.getExternalFilesDir("Pachli") if (externalFilesDir == null || !(externalFilesDir.exists())) { @@ -129,8 +129,9 @@ class DraftHelper @Inject constructor( statusId = statusId, ) - draftDao.upsert(draft) - Timber.d("saved draft to db") + val key = draftDao.upsert(draft).toInt() + Timber.d("saved draft to db: %s", key) + return@withContext if (draftId == 0) key else draftId } suspend fun deleteDraftAndAttachments(draftId: Int) { diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt b/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt index 6ff0934fb3..77d69c1c98 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt @@ -432,7 +432,6 @@ private fun getStatusComposeIntent( val status = body.status!! val account1 = status.actionableStatus.account val contentWarning = status.actionableStatus.spoilerText - val replyVisibility = status.actionableStatus.visibility val mentions = status.actionableStatus.mentions val language = status.actionableStatus.language @@ -444,11 +443,9 @@ private fun getStatusComposeIntent( } } val composeOptions = ComposeOptions( - replyVisibility = replyVisibility, contentWarning = contentWarning, inReplyTo = InReplyTo.Status.from(status), mentionedUsernames = mentionedUsernames, - modifiedInitialState = true, language = language, kind = ComposeOptions.ComposeKind.NEW, ) diff --git a/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusActivity.kt b/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusActivity.kt index f103265a01..07ae8cee25 100644 --- a/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusActivity.kt +++ b/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusActivity.kt @@ -164,7 +164,6 @@ class ScheduledStatusActivity : contentWarning = item.params.spoilerText, mediaAttachments = item.mediaAttachments, inReplyTo = item.params.inReplyToId?.let { InReplyTo.Id(it) }, - visibility = item.params.visibility, scheduledAt = item.scheduledAt, sensitive = item.params.sensitive, kind = ComposeOptions.ComposeKind.EDIT_SCHEDULED, diff --git a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt index 16167879d6..133f69688b 100644 --- a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt @@ -198,8 +198,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis requireContext(), status.pachliAccountId, ComposeOptions( - inReplyTo = InReplyTo.Status.from(status.actionable), - replyVisibility = actionableStatus.visibility, + inReplyTo = InReplyTo.Status.from(actionableStatus), contentWarning = actionableStatus.spoilerText, mentionedUsernames = mentionedUsernames, language = actionableStatus.language, diff --git a/app/src/main/java/app/pachli/fragment/SFragment.kt b/app/src/main/java/app/pachli/fragment/SFragment.kt index 43d8fa5e67..3066e7fbee 100644 --- a/app/src/main/java/app/pachli/fragment/SFragment.kt +++ b/app/src/main/java/app/pachli/fragment/SFragment.kt @@ -36,6 +36,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import app.pachli.R +import app.pachli.components.drafts.DraftHelper import app.pachli.core.activity.BaseActivity import app.pachli.core.activity.OpenUrlUseCase import app.pachli.core.activity.ViewUrlActivity @@ -68,10 +69,12 @@ import app.pachli.interfaces.StatusActionListener import app.pachli.translation.TranslationService import app.pachli.usecase.TimelineCases import app.pachli.view.showMuteAccountDialog +import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess import com.google.android.material.snackbar.Snackbar import io.github.z4kn4fein.semver.constraints.toConstraint +import java.util.Date import javax.inject.Inject import kotlinx.coroutines.launch import timber.log.Timber @@ -106,6 +109,9 @@ abstract class SFragment : Fragment(), StatusActionListener @Inject lateinit var openUrl: OpenUrlUseCase + @Inject + lateinit var draftHelper: DraftHelper + private var serverCanTranslate = false protected abstract val pachliAccountId: Long @@ -189,8 +195,7 @@ abstract class SFragment : Fragment(), StatusActionListener ).apply { remove(loggedInUsername) } val composeOptions = ComposeOptions( - inReplyTo = InReplyTo.Status.from(status.actionableStatus), - replyVisibility = actionableStatus.visibility, + inReplyTo = InReplyTo.Status.from(actionableStatus), contentWarning = actionableStatus.spoilerText, mentionedUsernames = mentionedUsernames, language = actionableStatus.language, @@ -477,6 +482,36 @@ abstract class SFragment : Fragment(), StatusActionListener .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> lifecycleScope.launch { + val status = statusViewData.actionable + + // status.content has HTML tags, so fetch the original content. + val source = mastodonApi.statusSource(status.id).getOrElse { + Timber.w("error getting status source: %s", it) + val context = requireContext() + val error = context.getString(app.pachli.core.network.R.string.error_generic_fmt, it.fmt(context)) + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + return@launch + }.body + + val draftId = draftHelper.saveDraft( + draftId = 0, + pachliAccountId = pachliAccountId, + inReplyToId = status.inReplyToId, + content = source.text, + contentWarning = source.spoilerText, + sensitive = status.sensitive, + visibility = status.visibility, + mediaUris = status.attachments.map { it.url }, + mediaDescriptions = status.attachments.map { it.description }, + mediaFocus = status.attachments.map { it.meta?.focus }, + poll = status.poll?.toNewPoll(Date()), + failedToSend = false, + failedToSendAlert = false, + language = status.language, + scheduledAt = null, + statusId = null, + ) + timelineCases.delete(statusViewData.status.id).onSuccess { val deletedStatus = it.body removeItem(statusViewData) @@ -492,10 +527,10 @@ abstract class SFragment : Fragment(), StatusActionListener contentWarning = sourceStatus.spoilerText, mediaAttachments = sourceStatus.attachments, sensitive = sourceStatus.sensitive, - modifiedInitialState = true, language = sourceStatus.language, poll = sourceStatus.poll?.toNewPoll(sourceStatus.createdAt), - kind = ComposeOptions.ComposeKind.NEW, + kind = ComposeOptions.ComposeKind.EDIT_DRAFT, + draftId = draftId, ) startActivityWithTransition( ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions), @@ -504,6 +539,7 @@ abstract class SFragment : Fragment(), StatusActionListener } .onFailure { Timber.w("error deleting status: %s", it) + draftHelper.deleteDraftAndAttachments(draftId) Toast.makeText(context, app.pachli.core.ui.R.string.error_generic, Toast.LENGTH_SHORT) .show() } diff --git a/app/src/main/java/app/pachli/util/LocaleUtils.kt b/app/src/main/java/app/pachli/util/LocaleUtils.kt index 95954c7565..9fcf43d3c3 100644 --- a/app/src/main/java/app/pachli/util/LocaleUtils.kt +++ b/app/src/main/java/app/pachli/util/LocaleUtils.kt @@ -58,6 +58,7 @@ private fun ensureLanguagesAreFirst(locales: MutableList, languages: Lis } } +// TODO: Doesn't need the full account, just the defaultPostLanguage fun getInitialLanguages(language: String? = null, activeAccount: AccountEntity? = null): List { val selected = listOfNotNull(language, activeAccount?.defaultPostLanguage) val system = AppCompatDelegate.getApplicationLocales().toList() + diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 4d0dfc63b7..31324a4187 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -311,6 +311,7 @@ android:layout_height="wrap_content" android:layout_marginTop="@dimen/compose_media_preview_margin" app:layout_constraintTop_toBottomOf="@id/composeMediaPreviewBar" + app:layout_constraintStart_toStartOf="parent" android:minWidth="@dimen/poll_preview_min_width" android:visibility="gone" tools:visibility="visible" /> @@ -377,7 +378,7 @@ app:behavior_peekHeight="0dp" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" /> - Flow.throttleFirst( private val DEFAULT_THROTTLE_FIRST_TIMEOUT = 500.milliseconds +/** + * Tracks the earliest and most recent emissions in a [Flow] and returns + * them as a [Pair]. + * + * The [first][Pair.first] item in the [Pair] is the earliest value in the original flow, + * the [second][Pair.second] item is the latest value in the original flow. + */ +fun Flow.trackFirstLast() = runningFold(null as Pair?) { acc, value -> (acc?.first ?: value) to value } + .filterNotNull() + +/** + * Returns a [Flow][Flow] that tracks the "dirty" state of the upstream + * flow. + * + * [predicate]'s arguments are the first emission in to the upstream flow (after + * collecting this flow), and the most recent emission in to the upstream flow. + * [predicate] should compare them and return either true (if they are meaningfully + * different) or false otherwise. + * + * This flow is distinct, so emissions from the upstream flow that do not change the + * dirty state do not generate a corresponding emission from this flow. + */ +inline fun Flow.dirtyIf(crossinline predicate: suspend (first: T, last: T) -> Boolean): Flow { + return trackFirstLast().map { predicate(it.first, it.second) }.distinctUntilChanged() +} + /* * Copyright 2022 Christophe Beyls * diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/DraftDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/DraftDao.kt index 38bf3d0350..3f92a8a5dc 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/DraftDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/DraftDao.kt @@ -27,7 +27,7 @@ import app.pachli.core.database.model.DraftEntity @Dao interface DraftDao { @Upsert - suspend fun upsert(draft: DraftEntity) + suspend fun upsert(draft: DraftEntity): Long @Query( """ diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/DraftEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/DraftEntity.kt index 99569e5a56..4d5e7be7f3 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/DraftEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/DraftEntity.kt @@ -34,6 +34,24 @@ import com.squareup.moshi.JsonClass import java.util.Date import kotlinx.parcelize.Parcelize +/** + * @param accountId + * @param inReplyToId If this draft is a reply to an existing status then this is the ID of + * that status. Null otherwise. + * @param content + * @param contentWarning + * @param sensitive + * @param visibility + * @param attachments + * @param poll + * @param failedToSend + * @param failedToSendNew + * @param scheduledAt + * @param language Language the draft is written in. Null to use the device's default + * language. + * @param statusId If this draft is an edit of an existing status then this is the ID of + * that status. Null otherwise. + */ @Entity( foreignKeys = [ ForeignKey( diff --git a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt index e28d76ac79..581363bbe6 100644 --- a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt +++ b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt @@ -353,15 +353,39 @@ class IntentRouterActivityIntent(context: Context, pachliAccountId: Long) : Inte * @see [app.pachli.components.compose.ComposeActivity] */ class ComposeActivityIntent(context: Context, pachliAccountId: Long, composeOptions: ComposeOptions? = null) : Intent() { + /** + * @param scheduledTootId Server ID of the scheduled status that should be updated when + * this status is sent. Null if this should be sent as a new status. + * @param draftId ID of the draft this status should be saved to if the user chooses to + * save and exit rather than post. 0 if a new draft should be created in this case. + * @param content + * @param mediaUrls + * @param mentionedUsernames + * @param visibility + * @param contentWarning + * @param inReplyTo Details of the status being replied to, if this a reply. Null if + * this is not a reply. + * @param mediaAttachments Existing media attachments on this status. Mutually exclusive + * with [draftAttachments]. + * @param draftAttachments Draft media attachments on this status. Attached, but not + * uploaded. Mutually exclusive with [mediaAttachments]. + * @param scheduledAt + * @param sensitive + * @param poll + * @param language Language the status is written in. Null to use the device's default + * language. + * @param statusId Server ID of the status that should be updated when this status is + * sent. Null if this should be sent as a new status. + * @param kind See [ComposeKind]. + * @param initialCursorPosition + */ @Parcelize data class ComposeOptions( val scheduledTootId: String? = null, - val draftId: Int? = null, + val draftId: Int = 0, val content: String? = null, val mediaUrls: List? = null, - val mediaDescriptions: List? = null, val mentionedUsernames: Set? = null, - val replyVisibility: Status.Visibility? = null, val visibility: Status.Visibility? = null, val contentWarning: String? = null, val inReplyTo: InReplyTo? = null, @@ -370,7 +394,6 @@ class ComposeActivityIntent(context: Context, pachliAccountId: Long, composeOpti val scheduledAt: Date? = null, val sensitive: Boolean? = null, val poll: NewPoll? = null, - val modifiedInitialState: Boolean? = null, val language: String? = null, val statusId: String? = null, val kind: ComposeKind? = null, @@ -431,6 +454,7 @@ class ComposeActivityIntent(context: Context, pachliAccountId: Long, composeOpti */ data class Status( override val statusId: String, + val visibility: Status.Visibility, val avatarUrl: String, val isBot: Boolean, val displayName: String, @@ -442,6 +466,7 @@ class ComposeActivityIntent(context: Context, pachliAccountId: Long, composeOpti companion object { fun from(status: app.pachli.core.network.model.Status) = Status( statusId = status.id, + visibility = status.visibility, avatarUrl = status.account.avatar, isBot = status.account.bot, displayName = status.account.name, @@ -465,8 +490,8 @@ class ComposeActivityIntent(context: Context, pachliAccountId: Long, composeOpti companion object { private const val EXTRA_COMPOSE_OPTIONS = "app.pachli.EXTRA_COMPOSE_OPTIONS" - /** @return the [ComposeOptions] passed in this intent, or null */ - fun getComposeOptions(intent: Intent) = IntentCompat.getParcelableExtra(intent, EXTRA_COMPOSE_OPTIONS, ComposeOptions::class.java) + /** @return the [ComposeOptions] passed in this intent, or default [ComposeOptions]. */ + fun getComposeOptions(intent: Intent) = IntentCompat.getParcelableExtra(intent, EXTRA_COMPOSE_OPTIONS, ComposeOptions::class.java) ?: ComposeOptions() } }