show status edits (#3049)
* show status edits part 1 * show status edits part 2 - load status edits * fix code formatting * add dialog to show status edits * small improvements * use ALIGN_CENTER to position status visibility icon when possible * rename status_timestamp_info view to status_meta_info * make dateFormat static * remove commented-out code * move edits to dedicated fragment
This commit is contained in:
parent
8c0f02cf33
commit
61a45ae376
26 changed files with 731 additions and 75 deletions
|
|
@ -83,7 +83,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||
setUsername(account.getUsername());
|
||||
setCreatedAt(status.getCreatedAt(), status.getEditedAt(), statusDisplayOptions);
|
||||
setMetaData(statusViewData, statusDisplayOptions, listener);
|
||||
setIsReply(status.getInReplyToId() != null);
|
||||
setFavourited(status.getFavourited());
|
||||
setBookmarked(status.getBookmarked());
|
||||
|
|
@ -121,7 +121,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
if (payloads instanceof List) {
|
||||
for (Object item : (List<?>) payloads) {
|
||||
if (Key.KEY_CREATED.equals(item)) {
|
||||
setCreatedAt(status.getCreatedAt(), status.getEditedAt(), statusDisplayOptions);
|
||||
setMetaData(statusViewData, statusDisplayOptions, listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
|
|
@ -33,6 +34,7 @@ import com.keylesspalace.tusky.AccountListActivity
|
|||
import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
||||
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
|
|
@ -104,6 +106,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
|
|||
binding.toolbar.setNavigationOnClickListener {
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
binding.toolbar.inflateMenu(R.menu.view_thread_toolbar)
|
||||
binding.toolbar.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_reveal -> {
|
||||
|
|
@ -325,6 +328,17 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
|
|||
viewModel.voteInPoll(choices, status)
|
||||
}
|
||||
|
||||
override fun onShowEdits(position: Int) {
|
||||
val status = adapter.currentList[position]
|
||||
val viewEditsFragment = ViewEditsFragment.newInstance(status.actionableId)
|
||||
|
||||
parentFragmentManager.commit {
|
||||
setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right)
|
||||
replace(R.id.fragment_container, viewEditsFragment, "ViewEditsFragment_$id")
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ViewThreadFragment"
|
||||
|
||||
|
|
|
|||
|
|
@ -364,7 +364,9 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete {
|
||||
private fun Status.toViewData(
|
||||
detailed: Boolean = false
|
||||
): StatusViewData.Concrete {
|
||||
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statuses?.find { it.id == this.id }
|
||||
return toViewData(
|
||||
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
package com.keylesspalace.tusky.components.viewthread.edits
|
||||
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.PollAdapter
|
||||
import com.keylesspalace.tusky.adapter.PollAdapter.Companion.MULTIPLE
|
||||
import com.keylesspalace.tusky.adapter.PollAdapter.Companion.SINGLE
|
||||
import com.keylesspalace.tusky.databinding.ItemStatusEditBinding
|
||||
import com.keylesspalace.tusky.entity.Attachment.Focus
|
||||
import com.keylesspalace.tusky.entity.StatusEdit
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.aspectRatios
|
||||
import com.keylesspalace.tusky.util.decodeBlurHash
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.toViewData
|
||||
|
||||
class ViewEditsAdapter(
|
||||
private val edits: List<StatusEdit>,
|
||||
private val animateAvatars: Boolean,
|
||||
private val animateEmojis: Boolean,
|
||||
private val useBlurhash: Boolean,
|
||||
private val listener: LinkListener
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemStatusEditBinding>>() {
|
||||
|
||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemStatusEditBinding> {
|
||||
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
binding.statusEditMediaPreview.clipToOutline = true
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemStatusEditBinding>, position: Int) {
|
||||
|
||||
val edit = edits[position]
|
||||
|
||||
val binding = holder.binding
|
||||
|
||||
val context = binding.root.context
|
||||
|
||||
val avatarRadius: Int = context.resources
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
|
||||
loadAvatar(edit.account.avatar, binding.statusEditAvatar, avatarRadius, animateAvatars)
|
||||
|
||||
val infoStringRes = if (position == edits.size - 1) {
|
||||
R.string.status_created_info
|
||||
} else {
|
||||
R.string.status_edit_info
|
||||
}
|
||||
|
||||
val timestamp = absoluteTimeFormatter.format(edit.createdAt, false)
|
||||
|
||||
binding.statusEditInfo.text = context.getString(
|
||||
infoStringRes,
|
||||
edit.account.name,
|
||||
timestamp
|
||||
).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis)
|
||||
|
||||
if (edit.spoilerText.isEmpty()) {
|
||||
binding.statusEditContentWarningDescription.hide()
|
||||
binding.statusEditContentWarningSeparator.hide()
|
||||
} else {
|
||||
binding.statusEditContentWarningDescription.show()
|
||||
binding.statusEditContentWarningSeparator.show()
|
||||
binding.statusEditContentWarningDescription.text = edit.spoilerText.emojify(
|
||||
edit.emojis,
|
||||
binding.statusEditContentWarningDescription,
|
||||
animateEmojis
|
||||
)
|
||||
}
|
||||
|
||||
val emojifiedText = edit.content.parseAsMastodonHtml().emojify(edit.emojis, binding.statusEditContent, animateEmojis)
|
||||
setClickableText(binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener)
|
||||
|
||||
if (edit.poll == null) {
|
||||
binding.statusEditPollOptions.hide()
|
||||
binding.statusEditPollDescription.hide()
|
||||
} else {
|
||||
binding.statusEditPollOptions.show()
|
||||
|
||||
// not used for now since not reported by the api
|
||||
// https://github.com/mastodon/mastodon/issues/22571
|
||||
// binding.statusEditPollDescription.show()
|
||||
|
||||
val pollAdapter = PollAdapter()
|
||||
binding.statusEditPollOptions.adapter = pollAdapter
|
||||
binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
pollAdapter.setup(
|
||||
options = edit.poll.options.map { it.toViewData(false) },
|
||||
voteCount = 0,
|
||||
votersCount = null,
|
||||
emojis = edit.emojis,
|
||||
mode = if (edit.poll.multiple) { // not reported by the api
|
||||
MULTIPLE
|
||||
} else {
|
||||
SINGLE
|
||||
},
|
||||
resultClickListener = null,
|
||||
animateEmojis = animateEmojis,
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
|
||||
if (edit.mediaAttachments.isEmpty()) {
|
||||
binding.statusEditMediaPreview.hide()
|
||||
binding.statusEditMediaSensitivity.hide()
|
||||
} else {
|
||||
binding.statusEditMediaPreview.show()
|
||||
binding.statusEditMediaPreview.aspectRatios = edit.mediaAttachments.aspectRatios()
|
||||
|
||||
binding.statusEditMediaPreview.forEachIndexed { index, _, imageView, descriptionIndicator ->
|
||||
|
||||
val attachment = edit.mediaAttachments[index]
|
||||
val hasDescription = !attachment.description.isNullOrBlank()
|
||||
|
||||
if (hasDescription) {
|
||||
imageView.contentDescription = attachment.description
|
||||
} else {
|
||||
imageView.contentDescription =
|
||||
imageView.context.getString(R.string.action_view_media)
|
||||
}
|
||||
descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE
|
||||
|
||||
val blurhash = attachment.blurhash
|
||||
|
||||
val placeholder: Drawable = if (blurhash != null && useBlurhash) {
|
||||
decodeBlurHash(context, blurhash)
|
||||
} else {
|
||||
ColorDrawable(ThemeUtils.getColor(context, R.attr.colorBackgroundAccent))
|
||||
}
|
||||
|
||||
if (attachment.previewUrl.isNullOrEmpty()) {
|
||||
imageView.removeFocalPoint()
|
||||
Glide.with(imageView)
|
||||
.load(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView)
|
||||
} else {
|
||||
val focus: Focus? = attachment.meta?.focus
|
||||
|
||||
if (focus != null) {
|
||||
imageView.setFocalPoint(focus)
|
||||
Glide.with(imageView.context)
|
||||
.load(attachment.previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.addListener(imageView)
|
||||
.into(imageView)
|
||||
} else {
|
||||
imageView.removeFocalPoint()
|
||||
Glide.with(imageView)
|
||||
.load(attachment.previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.statusEditMediaSensitivity.visible(edit.sensitive)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = edits.size
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
/* Copyright 2022 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.viewthread.edits
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentViewThreadBinding::bind)
|
||||
|
||||
private lateinit var statusId: String
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
binding.toolbar.setNavigationOnClickListener {
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
binding.toolbar.title = getString(R.string.title_edits)
|
||||
binding.swipeRefreshLayout.isEnabled = false
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
val divider = DividerItemDecoration(context, LinearLayout.VERTICAL)
|
||||
binding.recyclerView.addItemDecoration(divider)
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
statusId = requireArguments().getString(STATUS_ID_EXTRA)!!
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
|
||||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.uiState.collect { uiState ->
|
||||
when (uiState) {
|
||||
EditsUiState.Initial -> {}
|
||||
EditsUiState.Loading -> {
|
||||
binding.recyclerView.hide()
|
||||
binding.statusView.hide()
|
||||
binding.progressBar.show()
|
||||
}
|
||||
is EditsUiState.Error -> {
|
||||
Log.w(TAG, "failed to load edits", uiState.throwable)
|
||||
|
||||
binding.recyclerView.hide()
|
||||
binding.statusView.show()
|
||||
binding.progressBar.hide()
|
||||
|
||||
if (uiState.throwable is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
viewModel.loadEdits(statusId, force = true)
|
||||
}
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
viewModel.loadEdits(statusId, force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
is EditsUiState.Success -> {
|
||||
binding.recyclerView.show()
|
||||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
|
||||
binding.recyclerView.adapter = ViewEditsAdapter(
|
||||
edits = uiState.edits,
|
||||
animateAvatars = animateAvatars,
|
||||
animateEmojis = animateEmojis,
|
||||
useBlurhash = useBlurhash,
|
||||
listener = this@ViewEditsFragment
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.loadEdits(statusId)
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id))
|
||||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
bottomSheetActivity?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
}
|
||||
|
||||
override fun onViewUrl(url: String) {
|
||||
bottomSheetActivity?.viewUrl(url)
|
||||
}
|
||||
|
||||
private val bottomSheetActivity
|
||||
get() = (activity as? BottomSheetActivity)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ViewEditsFragment"
|
||||
|
||||
private const val STATUS_ID_EXTRA = "id"
|
||||
|
||||
fun newInstance(statusId: String): ViewEditsFragment {
|
||||
val arguments = Bundle(1)
|
||||
val fragment = ViewEditsFragment()
|
||||
arguments.putString(STATUS_ID_EXTRA, statusId)
|
||||
fragment.arguments = arguments
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/* Copyright 2022 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.viewthread.edits
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.entity.StatusEdit
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ViewEditsViewModel @Inject constructor(
|
||||
private val api: MastodonApi
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
|
||||
val uiState: Flow<EditsUiState>
|
||||
get() = _uiState
|
||||
|
||||
fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) {
|
||||
if (force || _uiState.value is EditsUiState.Initial) {
|
||||
if (!refreshing) {
|
||||
_uiState.value = EditsUiState.Loading
|
||||
}
|
||||
viewModelScope.launch {
|
||||
api.statusEdits(statusId).fold(
|
||||
{ edits ->
|
||||
val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed()
|
||||
_uiState.value = EditsUiState.Success(sortedEdits)
|
||||
},
|
||||
{ throwable ->
|
||||
_uiState.value = EditsUiState.Error(throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface EditsUiState {
|
||||
object Initial : EditsUiState
|
||||
object Loading : EditsUiState
|
||||
class Error(val throwable: Throwable) : EditsUiState
|
||||
data class Success(
|
||||
val edits: List<StatusEdit>
|
||||
) : EditsUiState
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue