rewrite threads with Kotlin & coroutines (#2617)
* initial class setup * handle events and filters * handle status state changes * code formatting * fix status filtering * cleanup code a bit * implement removeAllByAccountId * move toolbar into fragment, implement menu * error and load state handling * fix pull to refresh * implement reveal button * use requireContext() instead of context!! * jump to detailed status * add ViewThreadViewModelTest * fix ktlint * small code improvements (thx charlag) * add testcase for toggleRevealButton * add more state change testcases to ViewThreadViewModel
This commit is contained in:
parent
607f448eb3
commit
741461acde
24 changed files with 1446 additions and 999 deletions
|
|
@ -0,0 +1,68 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* 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.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
|
||||
class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val divider: Drawable = ContextCompat.getDrawable(context, R.drawable.conversation_thread_line)!!
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val dividerStart = parent.paddingStart + context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start)
|
||||
val dividerEnd = dividerStart + divider.intrinsicWidth
|
||||
|
||||
val childCount = parent.childCount
|
||||
val avatarMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin)
|
||||
|
||||
for (i in 0 until childCount) {
|
||||
val child = parent.getChildAt(i)
|
||||
|
||||
val position = parent.getChildAdapterPosition(child)
|
||||
val items = (parent.adapter as ThreadAdapter).currentList
|
||||
|
||||
val current = items.getOrNull(position)
|
||||
|
||||
if (current != null) {
|
||||
val above = items.getOrNull(position - 1)
|
||||
val dividerTop = if (above != null && above.id == current.status.inReplyToId) {
|
||||
child.top
|
||||
} else {
|
||||
child.top + avatarMargin
|
||||
}
|
||||
val below = items.getOrNull(position + 1)
|
||||
val dividerBottom = if (below != null && current.id == below.status.inReplyToId && below.isDetailed) {
|
||||
child.bottom
|
||||
} else {
|
||||
child.top + avatarMargin
|
||||
}
|
||||
|
||||
if (parent.layoutDirection == View.LAYOUT_DIRECTION_LTR) {
|
||||
divider.setBounds(dividerStart, dividerTop, dividerEnd, dividerBottom)
|
||||
} else {
|
||||
divider.setBounds(canvas.width - dividerEnd, dividerTop, canvas.width - dividerStart, dividerBottom)
|
||||
}
|
||||
divider.draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/* Copyright 2021 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.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
||||
import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder
|
||||
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
|
||||
class ThreadAdapter(
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private val statusActionListener: StatusActionListener
|
||||
) : ListAdapter<StatusViewData.Concrete, StatusBaseViewHolder>(ThreadDifferCallback) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder {
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_STATUS -> {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_status, parent, false)
|
||||
StatusViewHolder(view)
|
||||
}
|
||||
VIEW_TYPE_STATUS_DETAILED -> {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_status_detailed, parent, false)
|
||||
StatusDetailedViewHolder(view)
|
||||
}
|
||||
else -> error("Unknown item type: $viewType")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) {
|
||||
val status = getItem(position)
|
||||
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions)
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (getItem(position).isDetailed) {
|
||||
VIEW_TYPE_STATUS_DETAILED
|
||||
} else {
|
||||
VIEW_TYPE_STATUS
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_STATUS = 0
|
||||
private const val VIEW_TYPE_STATUS_DETAILED = 1
|
||||
|
||||
val ThreadDifferCallback = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: StatusViewData.Concrete,
|
||||
newItem: StatusViewData.Concrete
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: StatusViewData.Concrete,
|
||||
newItem: StatusViewData.Concrete
|
||||
): Boolean {
|
||||
return false // Items are different always. It allows to refresh timestamp on every view holder update
|
||||
}
|
||||
|
||||
override fun getChangePayload(
|
||||
oldItem: StatusViewData.Concrete,
|
||||
newItem: StatusViewData.Concrete
|
||||
): Any? {
|
||||
return if (oldItem == newItem) {
|
||||
// If items are equal - update timestamp only
|
||||
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
|
||||
} else // If items are different - update the whole view holder
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/* 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.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.commit
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
||||
class ViewThreadActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_view_thread)
|
||||
val id = intent.getStringExtra(ID_EXTRA)!!
|
||||
val url = intent.getStringExtra(URL_EXTRA)!!
|
||||
val fragment =
|
||||
supportFragmentManager.findFragmentByTag(FRAGMENT_TAG + id) as ViewThreadFragment?
|
||||
?: ViewThreadFragment.newInstance(id, url)
|
||||
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
||||
companion object {
|
||||
|
||||
fun startIntent(context: Context, id: String, url: String): Intent {
|
||||
val intent = Intent(context, ViewThreadActivity::class.java)
|
||||
intent.putExtra(ID_EXTRA, id)
|
||||
intent.putExtra(URL_EXTRA, url)
|
||||
return intent
|
||||
}
|
||||
|
||||
private const val ID_EXTRA = "id"
|
||||
private const val URL_EXTRA = "url"
|
||||
private const val FRAGMENT_TAG = "ViewThreadFragment_"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
/* 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.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.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.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)
|
||||
)
|
||||
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?.onBackPressed()
|
||||
}
|
||||
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
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.uiState.collect { uiState ->
|
||||
when (uiState) {
|
||||
is ThreadUiState.Loading -> {
|
||||
updateRevealButton(RevealButtonState.NO_BUTTON)
|
||||
binding.recyclerView.hide()
|
||||
binding.statusView.hide()
|
||||
binding.progressBar.show()
|
||||
}
|
||||
is ThreadUiState.Error -> {
|
||||
Log.w(TAG, "failed to load status", uiState.throwable)
|
||||
|
||||
updateRevealButton(RevealButtonState.NO_BUTTON)
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
|
||||
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.retry(thisThreadsStatusId)
|
||||
}
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
viewModel.retry(thisThreadsStatusId)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ThreadUiState.Success -> {
|
||||
adapter.submitList(uiState.statuses) {
|
||||
if (viewModel.isInitialLoad) {
|
||||
viewModel.isInitialLoad = false
|
||||
val detailedPosition = adapter.currentList.indexOfFirst { viewData ->
|
||||
viewData.isDetailed
|
||||
}
|
||||
binding.recyclerView.scrollToPosition(detailedPosition)
|
||||
}
|
||||
}
|
||||
|
||||
updateRevealButton(uiState.revealButton)
|
||||
binding.swipeRefreshLayout.isRefreshing = uiState.refreshing
|
||||
|
||||
binding.recyclerView.show()
|
||||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
/* 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.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.FavoriteEvent
|
||||
import com.keylesspalace.tusky.appstore.PinEvent
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.usecase.TimelineCases
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import javax.inject.Inject
|
||||
|
||||
class ViewThreadViewModel @Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
private val filterModel: FilterModel,
|
||||
private val timelineCases: TimelineCases,
|
||||
eventHub: EventHub,
|
||||
accountManager: AccountManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading)
|
||||
val uiState: Flow<ThreadUiState>
|
||||
get() = _uiState
|
||||
|
||||
private val _errors = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val errors: Flow<Throwable>
|
||||
get() = _errors
|
||||
|
||||
var isInitialLoad: Boolean = true
|
||||
|
||||
private val alwaysShowSensitiveMedia: Boolean
|
||||
private val alwaysOpenSpoiler: Boolean
|
||||
|
||||
init {
|
||||
val activeAccount = accountManager.activeAccount
|
||||
alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
||||
alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
||||
|
||||
viewModelScope.launch {
|
||||
eventHub.events
|
||||
.asFlow()
|
||||
.collect { event ->
|
||||
when (event) {
|
||||
is FavoriteEvent -> handleFavEvent(event)
|
||||
is ReblogEvent -> handleReblogEvent(event)
|
||||
is BookmarkEvent -> handleBookmarkEvent(event)
|
||||
is PinEvent -> handlePinEvent(event)
|
||||
is BlockEvent -> removeAllByAccountId(event.accountId)
|
||||
is StatusComposedEvent -> handleStatusComposedEvent(event)
|
||||
is StatusDeletedEvent -> handleStatusDeletedEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadFilters()
|
||||
}
|
||||
|
||||
fun loadThread(id: String) {
|
||||
viewModelScope.launch {
|
||||
val contextCall = async { api.statusContext(id) }
|
||||
val statusCall = async { api.statusAsync(id) }
|
||||
|
||||
val contextResult = contextCall.await()
|
||||
val statusResult = statusCall.await()
|
||||
|
||||
val status = statusResult.getOrElse { exception ->
|
||||
_uiState.value = ThreadUiState.Error(exception)
|
||||
return@launch
|
||||
}
|
||||
|
||||
contextResult.fold({ statusContext ->
|
||||
|
||||
val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter()
|
||||
val detailedStatus = status.toViewData(true)
|
||||
val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter()
|
||||
val statuses = ancestors + detailedStatus + descendants
|
||||
|
||||
_uiState.value = ThreadUiState.Success(
|
||||
statuses = statuses,
|
||||
revealButton = statuses.getRevealButtonState(),
|
||||
refreshing = false
|
||||
)
|
||||
}, { throwable ->
|
||||
_errors.emit(throwable)
|
||||
_uiState.value = ThreadUiState.Success(
|
||||
statuses = listOf(status.toViewData(true)),
|
||||
revealButton = RevealButtonState.NO_BUTTON,
|
||||
refreshing = false
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun retry(id: String) {
|
||||
_uiState.value = ThreadUiState.Loading
|
||||
loadThread(id)
|
||||
}
|
||||
|
||||
fun refresh(id: String) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(refreshing = true)
|
||||
}
|
||||
loadThread(id)
|
||||
}
|
||||
|
||||
fun detailedStatus(): StatusViewData.Concrete? {
|
||||
return (_uiState.value as ThreadUiState.Success?)?.statuses?.find { status ->
|
||||
status.isDetailed
|
||||
}
|
||||
}
|
||||
|
||||
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.reblog(status.actionableId, reblog).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to reblog status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.favourite(status.actionableId, favorite).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||
try {
|
||||
timelineCases.bookmark(status.actionableId, bookmark).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun voteInPoll(choices: List<Int>, status: StatusViewData.Concrete): Job = viewModelScope.launch {
|
||||
val poll = status.status.actionableStatus.poll ?: run {
|
||||
Log.w(TAG, "No poll on status ${status.id}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val votedPoll = poll.votedCopy(choices)
|
||||
updateStatus(status.id) { status ->
|
||||
status.copy(poll = votedPoll)
|
||||
}
|
||||
|
||||
try {
|
||||
timelineCases.voteInPoll(status.actionableId, poll.id, choices).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeStatus(statusToRemove: StatusViewData.Concrete) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(
|
||||
statuses = uiState.statuses.filterNot { status -> status == statusToRemove }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
|
||||
updateSuccess { uiState ->
|
||||
val statuses = uiState.statuses.map { viewData ->
|
||||
if (viewData.id == status.id) {
|
||||
viewData.copy(isExpanded = expanded)
|
||||
} else {
|
||||
viewData
|
||||
}
|
||||
}
|
||||
uiState.copy(
|
||||
statuses = statuses,
|
||||
revealButton = statuses.getRevealButtonState()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
|
||||
updateStatusViewData(status.id) { viewData ->
|
||||
viewData.copy(isShowingContent = isShowing)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
|
||||
updateStatusViewData(status.id) { viewData ->
|
||||
viewData.copy(isCollapsed = isCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFavEvent(event: FavoriteEvent) {
|
||||
updateStatus(event.statusId) { status ->
|
||||
status.copy(favourited = event.favourite)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReblogEvent(event: ReblogEvent) {
|
||||
updateStatus(event.statusId) { status ->
|
||||
status.copy(reblogged = event.reblog)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBookmarkEvent(event: BookmarkEvent) {
|
||||
updateStatus(event.statusId) { status ->
|
||||
status.copy(bookmarked = event.bookmark)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePinEvent(event: PinEvent) {
|
||||
updateStatus(event.statusId) { status ->
|
||||
status.copy(pinned = event.pinned)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeAllByAccountId(accountId: String) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(
|
||||
statuses = uiState.statuses.filter { viewData ->
|
||||
viewData.status.account.id == accountId
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStatusComposedEvent(event: StatusComposedEvent) {
|
||||
val eventStatus = event.status
|
||||
updateSuccess { uiState ->
|
||||
val statuses = uiState.statuses
|
||||
val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed }
|
||||
val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id }
|
||||
if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
|
||||
// there is a new reply to the detailed status or below -> display it
|
||||
val newStatuses = statuses.subList(0, repliedIndex + 1) +
|
||||
eventStatus.toViewData() +
|
||||
statuses.subList(repliedIndex + 1, statuses.size)
|
||||
uiState.copy(statuses = newStatuses)
|
||||
} else {
|
||||
uiState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStatusDeletedEvent(event: StatusDeletedEvent) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(
|
||||
statuses = uiState.statuses.filter { status ->
|
||||
status.id != event.statusId
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRevealButton() {
|
||||
updateSuccess { uiState ->
|
||||
when (uiState.revealButton) {
|
||||
RevealButtonState.HIDE -> uiState.copy(
|
||||
statuses = uiState.statuses.map { viewData ->
|
||||
viewData.copy(isExpanded = false)
|
||||
},
|
||||
revealButton = RevealButtonState.REVEAL
|
||||
)
|
||||
RevealButtonState.REVEAL -> uiState.copy(
|
||||
statuses = uiState.statuses.map { viewData ->
|
||||
viewData.copy(isExpanded = true)
|
||||
},
|
||||
revealButton = RevealButtonState.HIDE
|
||||
)
|
||||
else -> uiState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<StatusViewData.Concrete>.getRevealButtonState(): RevealButtonState {
|
||||
val hasWarnings = any { viewData ->
|
||||
viewData.status.spoilerText.isNotEmpty()
|
||||
}
|
||||
|
||||
return if (hasWarnings) {
|
||||
val allExpanded = none { viewData ->
|
||||
!viewData.isExpanded
|
||||
}
|
||||
if (allExpanded) {
|
||||
RevealButtonState.HIDE
|
||||
} else {
|
||||
RevealButtonState.REVEAL
|
||||
}
|
||||
} else {
|
||||
RevealButtonState.NO_BUTTON
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFilters() {
|
||||
viewModelScope.launch {
|
||||
val filters = try {
|
||||
api.getFilters().await()
|
||||
} catch (t: Exception) {
|
||||
Log.w(TAG, "Failed to fetch filters", t)
|
||||
return@launch
|
||||
}
|
||||
filterModel.initWithFilters(
|
||||
filters.filter { filter ->
|
||||
filter.context.contains(Filter.THREAD)
|
||||
}
|
||||
)
|
||||
|
||||
updateSuccess { uiState ->
|
||||
val statuses = uiState.statuses.filter()
|
||||
uiState.copy(
|
||||
statuses = statuses,
|
||||
revealButton = statuses.getRevealButtonState()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<StatusViewData.Concrete>.filter(): List<StatusViewData.Concrete> {
|
||||
return filter { status ->
|
||||
status.isDetailed || !filterModel.shouldFilterStatus(status.status)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete {
|
||||
return toViewData(
|
||||
isShowingContent = alwaysShowSensitiveMedia || !actionableStatus.sensitive,
|
||||
isExpanded = alwaysOpenSpoiler,
|
||||
isCollapsed = !detailed,
|
||||
isDetailed = detailed
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun updateSuccess(updater: (ThreadUiState.Success) -> ThreadUiState.Success) {
|
||||
_uiState.update { uiState ->
|
||||
if (uiState is ThreadUiState.Success) {
|
||||
updater(uiState)
|
||||
} else {
|
||||
uiState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(
|
||||
statuses = uiState.statuses.map { viewData ->
|
||||
if (viewData.id == statusId) {
|
||||
updater(viewData)
|
||||
} else {
|
||||
viewData
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStatus(statusId: String, updater: (Status) -> Status) {
|
||||
updateStatusViewData(statusId) { viewData ->
|
||||
viewData.copy(
|
||||
status = updater(viewData.status)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ViewThreadViewModel"
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ThreadUiState {
|
||||
object Loading : ThreadUiState
|
||||
class Error(val throwable: Throwable) : ThreadUiState
|
||||
data class Success(
|
||||
val statuses: List<StatusViewData.Concrete>,
|
||||
val revealButton: RevealButtonState,
|
||||
val refreshing: Boolean
|
||||
) : ThreadUiState
|
||||
}
|
||||
|
||||
enum class RevealButtonState {
|
||||
NO_BUTTON, REVEAL, HIDE
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue