Add post editing capability (#2828)

* Add post editing capability

* Don't try to reprocess already uploaded attachments.
Fixes editing posts with existing media

* Don't mark post edits as modified until editing occurs

* Disable UI for things that can't be edited when editing a post

* Finally convert SFragment to kotlin

* Use api endpoint for fetching status source for editing

* Apply review feedback
This commit is contained in:
Levi Bard 2022-12-08 10:18:12 +01:00 committed by GitHub
commit a6b6a40ba6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 676 additions and 527 deletions

View file

@ -238,15 +238,14 @@ class ComposeActivity :
binding.composeMediaPreviewBar.adapter = mediaAdapter
binding.composeMediaPreviewBar.itemAnimator = null
setupButtons()
subscribeToUpdates(mediaAdapter)
/* If the composer is started up as a reply to another post, override the "starting" state
* based on what the intent from the reply request passes. */
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupButtons()
subscribeToUpdates(mediaAdapter)
if (accountManager.shouldDisplaySelfUsername(this)) {
binding.composeUsernameView.text = getString(
R.string.compose_active_account_description,
@ -708,20 +707,25 @@ class ComposeActivity :
}
private fun updateScheduleButton() {
@ColorInt val color = if (binding.composeScheduleView.time == null) {
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
if (viewModel.editing) {
// Can't reschedule a published status
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
} else {
getColor(R.color.tusky_blue)
@ColorInt val color = if (binding.composeScheduleView.time == null) {
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
} else {
getColor(R.color.tusky_blue)
}
binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
private fun enableButtons(enable: Boolean) {
private fun enableButtons(enable: Boolean, editing: Boolean) {
binding.composeAddMediaButton.isClickable = enable
binding.composeToggleVisibilityButton.isClickable = enable
binding.composeToggleVisibilityButton.isClickable = enable && !editing
binding.composeEmojiButton.isClickable = enable
binding.composeHideMediaButton.isClickable = enable
binding.composeScheduleButton.isClickable = enable
binding.composeScheduleButton.isClickable = enable && !editing
binding.composeTootButton.isEnabled = enable
}
@ -737,6 +741,10 @@ class ComposeActivity :
else -> R.drawable.ic_lock_open_24dp
}
binding.composeToggleVisibilityButton.setImageResource(iconRes)
if (viewModel.editing) {
// Can't update visibility on published status
enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false)
}
}
private fun showComposeOptions() {
@ -938,7 +946,7 @@ class ComposeActivity :
}
private fun sendStatus() {
enableButtons(false)
enableButtons(false, viewModel.editing)
val contentText = binding.composeEditField.text.toString()
var spoilerText = ""
if (viewModel.showContentWarning.value) {
@ -947,7 +955,7 @@ class ComposeActivity :
val characterCount = calculateTextLength()
if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) {
binding.composeEditField.error = getString(R.string.error_empty)
enableButtons(true)
enableButtons(true, viewModel.editing)
} else if (characterCount <= maximumTootCharacters) {
if (viewModel.media.value.isNotEmpty()) {
finishingUploadDialog = ProgressDialog.show(
@ -963,7 +971,7 @@ class ComposeActivity :
}
} else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true)
enableButtons(true, viewModel.editing)
}
}
@ -1179,7 +1187,8 @@ class ComposeActivity :
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null,
val focus: Attachment.Focus? = null
val focus: Attachment.Focus? = null,
val processed: Boolean = false,
) {
enum class Type {
IMAGE, VIDEO, AUDIO;
@ -1230,6 +1239,7 @@ class ComposeActivity :
var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null,
var language: String? = null,
var statusId: String? = null,
) : Parcelable
companion object {

View file

@ -73,6 +73,7 @@ class ComposeViewModel @Inject constructor(
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
@ -193,7 +194,8 @@ class ComposeViewModel @Inject constructor(
uploadPercent = -1,
id = id,
description = description,
focus = focus
focus = focus,
processed = true,
)
mediaValue + mediaItem
}
@ -270,6 +272,7 @@ class ComposeViewModel @Inject constructor(
failedToSend = false,
scheduledAt = scheduledAt.value,
language = postLanguage,
statusId = originalStatusId,
)
}
@ -299,7 +302,7 @@ class ComposeViewModel @Inject constructor(
mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "")
mediaFocus.add(item.focus)
mediaProcessed.add(false)
mediaProcessed.add(item.processed)
}
val tootToSend = StatusToSend(
text = content,
@ -321,6 +324,7 @@ class ComposeViewModel @Inject constructor(
retries = 0,
mediaProcessed = mediaProcessed,
language = postLanguage,
statusId = originalStatusId,
)
serviceClient.sendToot(tootToSend)
@ -452,6 +456,7 @@ class ComposeViewModel @Inject constructor(
draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId
originalStatusId = composeOptions?.statusId
startingText = composeOptions?.content
postLanguage = composeOptions?.language
@ -497,6 +502,9 @@ class ComposeViewModel @Inject constructor(
scheduledAt.value = newScheduledAt
}
val editing: Boolean
get() = !originalStatusId.isNullOrEmpty()
private companion object {
const val TAG = "ComposeViewModel"
}

View file

@ -48,10 +48,13 @@ class MediaPreviewAdapter(
val addFocusId = 2
val editImageId = 3
val removeId = 4
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
if (!item.processed) {
// Already-published items can't have their metadata edited
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
}
}
popup.menu.add(0, removeId, 0, R.string.action_remove)
popup.setOnMenuItemClickListener { menuItem ->

View file

@ -65,6 +65,7 @@ class DraftHelper @Inject constructor(
failedToSend: Boolean,
scheduledAt: String?,
language: String?,
statusId: String?,
) = withContext(Dispatchers.IO) {
val externalFilesDir = context.getExternalFilesDir("Tusky")
@ -124,6 +125,7 @@ class DraftHelper @Inject constructor(
failedToSend = failedToSend,
scheduledAt = scheduledAt,
language = language,
statusId = statusId,
)
draftDao.insertOrReplace(draft)

View file

@ -111,6 +111,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
visibility = draft.visibility,
scheduledAt = draft.scheduledAt,
language = draft.language,
statusId = draft.statusId,
)
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
@ -147,6 +148,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
visibility = draft.visibility,
scheduledAt = draft.scheduledAt,
language = draft.language,
statusId = draft.statusId,
)
startActivity(ComposeActivity.startIntent(this, composeOptions))

View file

@ -22,6 +22,7 @@ import com.keylesspalace.tusky.databinding.FragmentSearchBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.Flow
@ -38,6 +39,9 @@ abstract class SearchFragment<T : Any> :
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var mastodonApi: MastodonApi
protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory }
protected val binding by viewBinding(FragmentSearchBinding::bind)

View file

@ -33,13 +33,16 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import at.connyduck.calladapter.networkresult.fold
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
@ -62,6 +65,7 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener {
@ -351,6 +355,10 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
showConfirmEditDialog(id, position, status)
return@setOnMenuItemClickListener true
}
R.id.status_edit -> {
editStatus(id, position, status)
return@setOnMenuItemClickListener true
}
R.id.pin -> {
viewModel.pinAccount(status, !status.isPinned())
return@setOnMenuItemClickListener true
@ -487,4 +495,32 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
.show()
}
}
private fun editStatus(id: String, position: Int, status: Status) {
lifecycleScope.launch {
mastodonApi.statusSource(id).fold(
{ source ->
val composeOptions = ComposeOptions(
content = source.text,
inReplyToId = status.inReplyToId,
visibility = status.visibility,
contentWarning = source.spoilerText,
mediaAttachments = status.attachments,
sensitive = status.sensitive,
language = status.language,
statusId = source.id,
poll = status.poll?.toNewPoll(status.createdAt),
)
startActivity(ComposeActivity.startIntent(requireContext(), composeOptions))
},
{
Snackbar.make(
requireView(),
getString(R.string.error_status_source_load),
Snackbar.LENGTH_SHORT
).show()
}
)
}
}
}

View file

@ -46,7 +46,6 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewM
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Status
@ -85,9 +84,6 @@ class TimelineFragment :
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var accountManager: AccountManager
private val viewModel: TimelineViewModel by lazy {
if (kind == TimelineViewModel.Kind.HOME) {
ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java]