419 lines
17 KiB
Kotlin
419 lines
17 KiB
Kotlin
/* 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
|
|
|
|
import android.os.Bundle
|
|
import android.util.Log
|
|
import android.view.LayoutInflater
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import android.widget.LinearLayout
|
|
import androidx.annotation.CheckResult
|
|
import androidx.fragment.app.commit
|
|
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 androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
|
import com.google.android.material.snackbar.Snackbar
|
|
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.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
|
|
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
|
|
import com.keylesspalace.tusky.fragment.SFragment
|
|
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
|
import com.keylesspalace.tusky.settings.PrefKeys
|
|
import com.keylesspalace.tusky.util.CardViewMode
|
|
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
|
|
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|
import com.keylesspalace.tusky.util.hide
|
|
import com.keylesspalace.tusky.util.openLink
|
|
import com.keylesspalace.tusky.util.show
|
|
import com.keylesspalace.tusky.util.viewBinding
|
|
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
|
|
import com.keylesspalace.tusky.viewdata.StatusViewData
|
|
import kotlinx.coroutines.CoroutineStart
|
|
import kotlinx.coroutines.awaitCancellation
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import java.io.IOException
|
|
import javax.inject.Inject
|
|
|
|
class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable {
|
|
|
|
@Inject
|
|
lateinit var viewModelFactory: ViewModelFactory
|
|
|
|
private val viewModel: ViewThreadViewModel by viewModels { viewModelFactory }
|
|
|
|
private val binding by viewBinding(FragmentViewThreadBinding::bind)
|
|
|
|
private lateinit var adapter: ThreadAdapter
|
|
private lateinit var thisThreadsStatusId: String
|
|
|
|
private var alwaysShowSensitiveMedia = false
|
|
private var alwaysOpenSpoiler = false
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!!
|
|
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
|
|
|
val statusDisplayOptions = StatusDisplayOptions(
|
|
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
|
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
|
|
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
|
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
|
|
useBlurhash = preferences.getBoolean("useBlurhash", true),
|
|
cardViewMode = if (preferences.getBoolean("showCardsInTimelines", false)) {
|
|
CardViewMode.INDENTED
|
|
} else {
|
|
CardViewMode.NONE
|
|
},
|
|
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
|
|
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
|
|
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
|
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
|
quoteEnabled = accountManager.activeAccount!!.domain in CAN_USE_QUOTE_ID
|
|
)
|
|
adapter = ThreadAdapter(statusDisplayOptions, this)
|
|
}
|
|
|
|
override fun onCreateView(
|
|
inflater: LayoutInflater,
|
|
container: ViewGroup?,
|
|
savedInstanceState: Bundle?
|
|
): View? {
|
|
return inflater.inflate(R.layout.fragment_view_thread, container, false)
|
|
}
|
|
|
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
|
|
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 -> {
|
|
viewModel.toggleRevealButton()
|
|
true
|
|
}
|
|
R.id.action_open_in_web -> {
|
|
context?.openLink(requireArguments().getString(URL_EXTRA)!!)
|
|
true
|
|
}
|
|
else -> false
|
|
}
|
|
}
|
|
|
|
binding.swipeRefreshLayout.setOnRefreshListener(this)
|
|
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
|
|
|
binding.recyclerView.setHasFixedSize(true)
|
|
binding.recyclerView.layoutManager = LinearLayoutManager(context)
|
|
binding.recyclerView.setAccessibilityDelegateCompat(
|
|
ListStatusAccessibilityDelegate(
|
|
binding.recyclerView,
|
|
this
|
|
) { index -> adapter.currentList.getOrNull(index) }
|
|
)
|
|
val divider = DividerItemDecoration(context, LinearLayout.VERTICAL)
|
|
binding.recyclerView.addItemDecoration(divider)
|
|
binding.recyclerView.addItemDecoration(ConversationLineItemDecoration(requireContext()))
|
|
alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
|
alwaysOpenSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
|
|
|
|
binding.recyclerView.adapter = adapter
|
|
|
|
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
|
|
|
var initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500)
|
|
var threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500)
|
|
|
|
viewLifecycleOwner.lifecycleScope.launch {
|
|
viewModel.uiState.collect { uiState ->
|
|
when (uiState) {
|
|
is ThreadUiState.Loading -> {
|
|
updateRevealButton(RevealButtonState.NO_BUTTON)
|
|
|
|
binding.recyclerView.hide()
|
|
binding.statusView.hide()
|
|
|
|
initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500)
|
|
initialProgressBar.start()
|
|
}
|
|
is ThreadUiState.LoadingThread -> {
|
|
if (uiState.statusViewDatum == null) {
|
|
// no detailed statuses available, e.g. because author is blocked
|
|
activity?.finish()
|
|
return@collect
|
|
}
|
|
|
|
initialProgressBar.cancel()
|
|
threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500)
|
|
threadProgressBar.start()
|
|
|
|
adapter.submitList(listOf(uiState.statusViewDatum))
|
|
|
|
updateRevealButton(uiState.revealButton)
|
|
binding.swipeRefreshLayout.isRefreshing = false
|
|
|
|
binding.recyclerView.show()
|
|
binding.statusView.hide()
|
|
}
|
|
is ThreadUiState.Error -> {
|
|
Log.w(TAG, "failed to load status", uiState.throwable)
|
|
initialProgressBar.cancel()
|
|
threadProgressBar.cancel()
|
|
|
|
updateRevealButton(RevealButtonState.NO_BUTTON)
|
|
binding.swipeRefreshLayout.isRefreshing = false
|
|
|
|
binding.recyclerView.hide()
|
|
binding.statusView.show()
|
|
|
|
if (uiState.throwable is IOException) {
|
|
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
|
viewModel.retry(thisThreadsStatusId)
|
|
}
|
|
} else {
|
|
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
|
viewModel.retry(thisThreadsStatusId)
|
|
}
|
|
}
|
|
}
|
|
is ThreadUiState.Success -> {
|
|
if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {
|
|
// no detailed statuses available, e.g. because author is blocked
|
|
activity?.finish()
|
|
return@collect
|
|
}
|
|
|
|
threadProgressBar.cancel()
|
|
|
|
adapter.submitList(uiState.statusViewData) {
|
|
if (viewModel.isInitialLoad) {
|
|
viewModel.isInitialLoad = false
|
|
|
|
// Ensure the top of the status is visible
|
|
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(uiState.detailedStatusPosition, 0)
|
|
}
|
|
}
|
|
|
|
updateRevealButton(uiState.revealButton)
|
|
binding.swipeRefreshLayout.isRefreshing = false
|
|
|
|
binding.recyclerView.show()
|
|
binding.statusView.hide()
|
|
}
|
|
is ThreadUiState.Refreshing -> {
|
|
threadProgressBar.cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
lifecycleScope.launch {
|
|
viewModel.errors.collect { throwable ->
|
|
Log.w(TAG, "failed to load status context", throwable)
|
|
Snackbar.make(binding.root, R.string.error_generic, Snackbar.LENGTH_SHORT)
|
|
.setAction(R.string.action_retry) {
|
|
viewModel.retry(thisThreadsStatusId)
|
|
}
|
|
.show()
|
|
}
|
|
}
|
|
|
|
viewModel.loadThread(thisThreadsStatusId)
|
|
}
|
|
|
|
/**
|
|
* Create a job to implement a delayed-visible progress bar.
|
|
*
|
|
* Delaying the visibility of the progress bar can improve user perception of UI speed because
|
|
* fewer UI elements are appearing and disappearing.
|
|
*
|
|
* When started the job will wait `delayMs` then show `view`. If the job is cancelled at
|
|
* any time `view` is hidden.
|
|
*/
|
|
@CheckResult()
|
|
private fun getProgressBarJob(view: View, delayMs: Long) = viewLifecycleOwner.lifecycleScope.launch(
|
|
start = CoroutineStart.LAZY
|
|
) {
|
|
try {
|
|
delay(delayMs)
|
|
view.show()
|
|
awaitCancellation()
|
|
} finally {
|
|
view.hide()
|
|
}
|
|
}
|
|
|
|
private fun updateRevealButton(state: RevealButtonState) {
|
|
val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal)
|
|
|
|
menuItem.isVisible = state != RevealButtonState.NO_BUTTON
|
|
menuItem.setIcon(if (state == RevealButtonState.REVEAL) R.drawable.ic_eye_24dp else R.drawable.ic_hide_media_24dp)
|
|
}
|
|
|
|
override fun onRefresh() {
|
|
viewModel.refresh(thisThreadsStatusId)
|
|
}
|
|
|
|
override fun onReply(position: Int) {
|
|
super.reply(adapter.currentList[position].status)
|
|
}
|
|
|
|
override fun onReblog(reblog: Boolean, position: Int) {
|
|
val status = adapter.currentList[position]
|
|
viewModel.reblog(reblog, status)
|
|
}
|
|
|
|
override fun onFavourite(favourite: Boolean, position: Int) {
|
|
val status = adapter.currentList[position]
|
|
viewModel.favorite(favourite, status)
|
|
}
|
|
|
|
override fun onQuote(position: Int) {
|
|
super.quote(adapter.currentList[position].status)
|
|
}
|
|
|
|
override fun onBookmark(bookmark: Boolean, position: Int) {
|
|
val status = adapter.currentList[position]
|
|
viewModel.bookmark(bookmark, status)
|
|
}
|
|
|
|
override fun onMore(view: View, position: Int) {
|
|
super.more(adapter.currentList[position].status, view, position)
|
|
}
|
|
|
|
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
|
|
val status = adapter.currentList[position].status
|
|
super.viewMedia(attachmentIndex, list(status), view)
|
|
}
|
|
|
|
override fun onViewThread(position: Int) {
|
|
val status = adapter.currentList[position]
|
|
if (thisThreadsStatusId == status.id) {
|
|
// If already viewing this thread, don't reopen it.
|
|
return
|
|
}
|
|
super.viewThread(status.actionableId, status.actionable.url)
|
|
}
|
|
|
|
override fun onViewUrl(url: String, text: String) {
|
|
val status: StatusViewData.Concrete? = viewModel.detailedStatus()
|
|
if (status != null && status.status.url == url) {
|
|
// already viewing the status with this url
|
|
// probably just a preview federated and the user is clicking again to view more -> open the browser
|
|
// this can happen with some friendica statuses
|
|
requireContext().openLink(url)
|
|
return
|
|
}
|
|
super.onViewUrl(url, text)
|
|
}
|
|
|
|
override fun onOpenReblog(position: Int) {
|
|
// there are no reblogs in threads
|
|
}
|
|
|
|
override fun onExpandedChange(expanded: Boolean, position: Int) {
|
|
viewModel.changeExpanded(expanded, adapter.currentList[position])
|
|
}
|
|
|
|
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
|
|
viewModel.changeContentShowing(isShowing, adapter.currentList[position])
|
|
}
|
|
|
|
override fun onLoadMore(position: Int) {
|
|
// only used in timelines
|
|
}
|
|
|
|
override fun onShowReblogs(position: Int) {
|
|
val statusId = adapter.currentList[position].id
|
|
val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId)
|
|
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent)
|
|
}
|
|
|
|
override fun onShowFavs(position: Int) {
|
|
val statusId = adapter.currentList[position].id
|
|
val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId)
|
|
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent)
|
|
}
|
|
|
|
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
|
|
viewModel.changeContentCollapsed(isCollapsed, adapter.currentList[position])
|
|
}
|
|
|
|
override fun onViewTag(tag: String) {
|
|
super.viewTag(tag)
|
|
}
|
|
|
|
override fun onViewAccount(id: String) {
|
|
super.viewAccount(id)
|
|
}
|
|
|
|
public override fun removeItem(position: Int) {
|
|
val status = adapter.currentList[position]
|
|
if (status.isDetailed) {
|
|
// the main status we are viewing is being removed, finish the activity
|
|
activity?.finish()
|
|
return
|
|
}
|
|
viewModel.removeStatus(status)
|
|
}
|
|
|
|
override fun onVoteInPoll(position: Int, choices: List<Int>) {
|
|
val status = adapter.currentList[position]
|
|
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"
|
|
|
|
private const val ID_EXTRA = "id"
|
|
private const val URL_EXTRA = "url"
|
|
|
|
fun newInstance(id: String, url: String): ViewThreadFragment {
|
|
val arguments = Bundle(2)
|
|
val fragment = ViewThreadFragment()
|
|
arguments.putString(ID_EXTRA, id)
|
|
arguments.putString(URL_EXTRA, url)
|
|
fragment.arguments = arguments
|
|
return fragment
|
|
}
|
|
}
|
|
}
|