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:
Konrad Pozniak 2022-08-15 11:00:18 +02:00 committed by GitHub
parent 607f448eb3
commit 741461acde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1446 additions and 999 deletions

View file

@ -98,7 +98,7 @@
android:theme="@style/TuskyDialogActivityTheme"
android:windowSoftInputMode="stateVisible|adjustResize" />
<activity
android:name=".ViewThreadActivity"
android:name=".components.viewthread.ViewThreadActivity"
android:configChanges="orientation|screenSize" />
<activity
android:name=".ViewMediaActivity"

View file

@ -27,6 +27,7 @@ import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.openLink
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers

View file

@ -47,6 +47,7 @@ import autodispose2.autoDispose
import com.bumptech.glide.Glide
import com.bumptech.glide.request.FutureTarget
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.fragment.ViewImageFragment

View file

@ -1,130 +0,0 @@
/* 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;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentTransaction;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.Toolbar;
import android.view.Menu;
import android.view.MenuItem;
import com.keylesspalace.tusky.fragment.ViewThreadFragment;
import com.keylesspalace.tusky.util.LinkHelper;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.HasAndroidInjector;
public class ViewThreadActivity extends BottomSheetActivity implements HasAndroidInjector {
public static final int REVEAL_BUTTON_HIDDEN = 1;
public static final int REVEAL_BUTTON_REVEAL = 2;
public static final int REVEAL_BUTTON_HIDE = 3;
public static Intent startIntent(Context context, String id, String url) {
Intent intent = new Intent(context, ViewThreadActivity.class);
intent.putExtra(ID_EXTRA, id);
intent.putExtra(URL_EXTRA, url);
return intent;
}
private static final String ID_EXTRA = "id";
private static final String URL_EXTRA = "url";
private static final String FRAGMENT_TAG = "ViewThreadFragment_";
private int revealButtonState = REVEAL_BUTTON_HIDDEN;
@Inject
public DispatchingAndroidInjector<Object> dispatchingAndroidInjector;
private ViewThreadFragment fragment;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_view_thread);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setTitle(R.string.title_view_thread);
actionBar.setDisplayHomeAsUpEnabled(true);
actionBar.setDisplayShowHomeEnabled(true);
}
String id = getIntent().getStringExtra(ID_EXTRA);
fragment = (ViewThreadFragment)getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG + id);
if(fragment == null) {
fragment = ViewThreadFragment.newInstance(id);
}
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
fragmentTransaction.replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id);
fragmentTransaction.commit();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.view_thread_toolbar, menu);
MenuItem menuItem = menu.findItem(R.id.action_reveal);
menuItem.setVisible(revealButtonState != REVEAL_BUTTON_HIDDEN);
menuItem.setIcon(revealButtonState == REVEAL_BUTTON_REVEAL ?
R.drawable.ic_eye_24dp : R.drawable.ic_hide_media_24dp);
return super.onCreateOptionsMenu(menu);
}
public void setRevealButtonState(int state) {
switch (state) {
case REVEAL_BUTTON_HIDDEN:
case REVEAL_BUTTON_REVEAL:
case REVEAL_BUTTON_HIDE:
this.revealButtonState = state;
invalidateOptionsMenu();
break;
default:
throw new IllegalArgumentException("Invalid reveal button state: " + state);
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_open_in_web: {
openLink(getIntent().getStringExtra(URL_EXTRA));
return true;
}
case R.id.action_reveal: {
fragment.onRevealPressed();
return true;
}
}
return super.onOptionsItemSelected(item);
}
@Override
public AndroidInjector<Object> androidInjector() {
return dispatchingAndroidInjector;
}
}

View file

@ -21,12 +21,12 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.DateFormat;
import java.util.Date;
class StatusDetailedViewHolder extends StatusBaseViewHolder {
private TextView reblogs;
private TextView favourites;
private View infoDivider;
public class StatusDetailedViewHolder extends StatusBaseViewHolder {
private final TextView reblogs;
private final TextView favourites;
private final View infoDivider;
StatusDetailedViewHolder(View view) {
public StatusDetailedViewHolder(View view) {
super(view);
reblogs = view.findViewById(R.id.status_reblogs);
favourites = view.findViewById(R.id.status_favourites);

View file

@ -1,129 +0,0 @@
/* 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.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
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
) : RecyclerView.Adapter<StatusBaseViewHolder>() {
private val statuses = mutableListOf<StatusViewData.Concrete>()
var detailedStatusPosition: Int = RecyclerView.NO_POSITION
private set
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 = statuses[position]
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions)
}
override fun getItemViewType(position: Int): Int {
return if (position == detailedStatusPosition) {
VIEW_TYPE_STATUS_DETAILED
} else {
VIEW_TYPE_STATUS
}
}
override fun getItemCount(): Int = statuses.size
fun setStatuses(statuses: List<StatusViewData.Concrete>?) {
this.statuses.clear()
this.statuses.addAll(statuses!!)
notifyDataSetChanged()
}
fun addItem(position: Int, statusViewData: StatusViewData.Concrete) {
statuses.add(position, statusViewData)
notifyItemInserted(position)
}
fun clearItems() {
val oldSize = statuses.size
statuses.clear()
detailedStatusPosition = RecyclerView.NO_POSITION
notifyItemRangeRemoved(0, oldSize)
}
fun addAll(position: Int, statuses: List<StatusViewData.Concrete>) {
this.statuses.addAll(position, statuses)
notifyItemRangeInserted(position, statuses.size)
}
fun addAll(statuses: List<StatusViewData.Concrete>) {
val end = statuses.size
this.statuses.addAll(statuses)
notifyItemRangeInserted(end, statuses.size)
}
fun removeItem(position: Int) {
statuses.removeAt(position)
notifyItemRemoved(position)
}
fun clear() {
statuses.clear()
detailedStatusPosition = RecyclerView.NO_POSITION
notifyDataSetChanged()
}
fun setItem(position: Int, status: StatusViewData.Concrete, notifyAdapter: Boolean) {
statuses[position] = status
if (notifyAdapter) {
notifyItemChanged(position)
}
}
fun getItem(position: Int): StatusViewData.Concrete? = statuses.getOrNull(position)
fun setDetailedStatusPosition(position: Int) {
if (position != detailedStatusPosition &&
detailedStatusPosition != RecyclerView.NO_POSITION
) {
val prior = detailedStatusPosition
detailedStatusPosition = position
notifyItemChanged(prior)
} else {
detailedStatusPosition = position
}
}
companion object {
private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_DETAILED = 1
}
}

View file

@ -13,7 +13,7 @@
* 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.view
package com.keylesspalace.tusky.components.viewthread
import android.content.Context
import android.graphics.Canvas
@ -22,7 +22,6 @@ import android.view.View
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.ThreadAdapter
class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() {
@ -39,22 +38,19 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie
val child = parent.getChildAt(i)
val position = parent.getChildAdapterPosition(child)
val adapter = parent.adapter as ThreadAdapter
val items = (parent.adapter as ThreadAdapter).currentList
val current = items.getOrNull(position)
val current = adapter.getItem(position)
val dividerTop: Int
val dividerBottom: Int
if (current != null) {
val above = adapter.getItem(position - 1)
dividerTop = if (above != null && above.id == current.status.inReplyToId) {
val above = items.getOrNull(position - 1)
val dividerTop = if (above != null && above.id == current.status.inReplyToId) {
child.top
} else {
child.top + avatarMargin
}
val below = adapter.getItem(position + 1)
dividerBottom = if (below != null && current.id == below.status.inReplyToId &&
adapter.detailedStatusPosition != position
) {
val below = items.getOrNull(position + 1)
val dividerBottom = if (below != null && current.id == below.status.inReplyToId && below.isDetailed) {
child.bottom
} else {
child.top + avatarMargin

View file

@ -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
}
}
}
}

View file

@ -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_"
}
}

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -27,7 +27,6 @@ import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewThreadActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
@ -39,6 +38,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import dagger.Module
import dagger.android.ContributesAndroidInjector

View file

@ -29,9 +29,9 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
import com.keylesspalace.tusky.fragment.AccountListFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.fragment.ViewThreadFragment
import dagger.Module
import dagger.android.ContributesAndroidInjector

View file

@ -14,6 +14,7 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.keylesspalace.tusky.viewmodel.ListsViewModel
@ -108,5 +109,9 @@ abstract class ViewModelModule {
@ViewModelKey(NetworkTimelineViewModel::class)
internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(ViewThreadViewModel::class)
internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel
// Add more ViewModels here
}

View file

@ -1,683 +0,0 @@
/* 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.fragment;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.arch.core.util.Function;
import androidx.lifecycle.Lifecycle;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.AccountListActivity;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewThreadActivity;
import com.keylesspalace.tusky.adapter.ThreadAdapter;
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.di.Injectable;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.FilterModel;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.ViewDataUtils;
import com.keylesspalace.tusky.view.ConversationLineItemDecoration;
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import javax.inject.Inject;
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import kotlin.collections.CollectionsKt;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
public final class ViewThreadFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, Injectable {
private static final String TAG = "ViewThreadFragment";
@Inject
public MastodonApi mastodonApi;
@Inject
public EventHub eventHub;
@Inject
public FilterModel filterModel;
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private ThreadAdapter adapter;
private String thisThreadsStatusId;
private boolean alwaysShowSensitiveMedia;
private boolean alwaysOpenSpoiler;
private int statusIndex = 0;
private final PairedList<Status, StatusViewData.Concrete> statuses =
new PairedList<>(new Function<Status, StatusViewData.Concrete>() {
@Override
public StatusViewData.Concrete apply(Status status) {
return ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia || !status.getActionableStatus().getSensitive(),
alwaysOpenSpoiler,
true
);
}
});
public static ViewThreadFragment newInstance(String id) {
Bundle arguments = new Bundle(1);
ViewThreadFragment fragment = new ViewThreadFragment();
arguments.putString("id", id);
fragment.setArguments(arguments);
return fragment;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
thisThreadsStatusId = getArguments().getString("id");
SharedPreferences preferences =
PreferenceManager.getDefaultSharedPreferences(getActivity());
StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions(
preferences.getBoolean("animateGifAvatars", false),
accountManager.getActiveAccount().getMediaPreviewEnabled(),
preferences.getBoolean("absoluteTimeView", false),
preferences.getBoolean("showBotOverlay", true),
preferences.getBoolean("useBlurhash", true),
preferences.getBoolean("showCardsInTimelines", false) ?
CardViewMode.INDENTED :
CardViewMode.NONE,
preferences.getBoolean("confirmReblogs", true),
preferences.getBoolean("confirmFavourites", false),
preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
);
adapter = new ThreadAdapter(statusDisplayOptions, this);
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false);
Context context = getContext();
swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout);
swipeRefreshLayout.setOnRefreshListener(this);
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue);
recyclerView = rootView.findViewById(R.id.recyclerView);
recyclerView.setHasFixedSize(true);
LinearLayoutManager layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAccessibilityDelegateCompat(
new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItemOrNull));
DividerItemDecoration divider = new DividerItemDecoration(
context, layoutManager.getOrientation());
recyclerView.addItemDecoration(divider);
recyclerView.addItemDecoration(new ConversationLineItemDecoration(context));
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
reloadFilters();
recyclerView.setAdapter(adapter);
statuses.clear();
((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
return rootView;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
onRefresh();
eventHub.getEvents()
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(event -> {
if (event instanceof FavoriteEvent) {
handleFavEvent((FavoriteEvent) event);
} else if (event instanceof ReblogEvent) {
handleReblogEvent((ReblogEvent) event);
} else if (event instanceof BookmarkEvent) {
handleBookmarkEvent((BookmarkEvent) event);
} else if (event instanceof PinEvent) {
handlePinEvent(((PinEvent) event));
} else if (event instanceof BlockEvent) {
removeAllByAccountId(((BlockEvent) event).getAccountId());
} else if (event instanceof StatusComposedEvent) {
handleStatusComposedEvent((StatusComposedEvent) event);
} else if (event instanceof StatusDeletedEvent) {
handleStatusDeletedEvent((StatusDeletedEvent) event);
}
});
}
public void onRevealPressed() {
boolean allExpanded = allExpanded();
for (int i = 0; i < statuses.size(); i++) {
updateViewData(i, statuses.getPairedItem(i).copyWithExpanded(!allExpanded));
}
updateRevealIcon();
}
private boolean allExpanded() {
boolean allExpanded = true;
for (int i = 0; i < statuses.size(); i++) {
if (!statuses.getPairedItem(i).isExpanded()) {
allExpanded = false;
break;
}
}
return allExpanded;
}
@Override
public void onRefresh() {
sendStatusRequest(thisThreadsStatusId);
sendThreadRequest(thisThreadsStatusId);
}
@Override
public void onReply(int position) {
super.reply(statuses.get(position));
}
@Override
public void onReblog(final boolean reblog, final int position) {
final Status status = statuses.get(position);
timelineCases.reblog(statuses.get(position).getId(), reblog)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
this::replaceStatus,
(t) -> Log.d(TAG,
"Failed to reblog status: " + status.getId(), t)
);
}
@Override
public void onFavourite(final boolean favourite, final int position) {
final Status status = statuses.get(position);
timelineCases.favourite(statuses.get(position).getId(), favourite)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
this::replaceStatus,
(t) -> Log.d(TAG,
"Failed to favourite status: " + status.getId(), t)
);
}
@Override
public void onBookmark(final boolean bookmark, final int position) {
final Status status = statuses.get(position);
timelineCases.bookmark(statuses.get(position).getId(), bookmark)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
this::replaceStatus,
(t) -> Log.d(TAG,
"Failed to bookmark status: " + status.getId(), t)
);
}
private void replaceStatus(Status status) {
updateStatus(status.getId(), (__) -> status);
}
private void updateStatus(String statusId, Function<Status, Status> mapper) {
int position = indexOfStatus(statusId);
if (position >= 0 && position < statuses.size()) {
Status oldStatus = statuses.get(position);
Status newStatus = mapper.apply(oldStatus);
StatusViewData.Concrete oldViewData = statuses.getPairedItem(position);
statuses.set(position, newStatus);
updateViewData(position, oldViewData.copyWithStatus(newStatus));
}
}
@Override
public void onMore(@NonNull View view, int position) {
super.more(statuses.get(position), view, position);
}
@Override
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) {
Status status = statuses.get(position);
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view);
}
@Override
public void onViewThread(int position) {
Status status = statuses.get(position);
if (thisThreadsStatusId.equals(status.getId())) {
// If already viewing this thread, don't reopen it.
return;
}
super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl());
}
@Override
public void onViewUrl(String url) {
Status status = null;
if (!statuses.isEmpty()) {
status = statuses.get(statusIndex);
}
if (status != null && status.getUrl().equals(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
LinkHelper.openLink(requireContext(), url);
return;
}
super.onViewUrl(url);
}
@Override
public void onOpenReblog(int position) {
// there should be no reblogs in the thread but let's implement it to be sure
super.openReblog(statuses.get(position));
}
@Override
public void onExpandedChange(boolean expanded, int position) {
updateViewData(
position,
statuses.getPairedItem(position).copyWithExpanded(expanded)
);
updateRevealIcon();
}
@Override
public void onContentHiddenChange(boolean isShowing, int position) {
updateViewData(
position,
statuses.getPairedItem(position).copyWithShowingContent(isShowing)
);
}
private void updateViewData(int position, StatusViewData.Concrete newViewData) {
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
}
@Override
public void onLoadMore(int position) {
}
@Override
public void onShowReblogs(int position) {
String statusId = statuses.get(position).getId();
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId);
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent);
}
@Override
public void onShowFavs(int position) {
String statusId = statuses.get(position).getId();
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId);
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent);
}
@Override
public void onContentCollapsedChange(boolean isCollapsed, int position) {
adapter.setItem(
position,
statuses.getPairedItem(position).copyWithCollapsed(isCollapsed),
true
);
}
@Override
public void onViewTag(String tag) {
super.viewTag(tag);
}
@Override
public void onViewAccount(String id) {
super.viewAccount(id);
}
@Override
public void removeItem(int position) {
if (position == statusIndex) {
//the status got removed, close the activity
getActivity().finish();
}
statuses.remove(position);
adapter.setStatuses(statuses.getPairedCopy());
}
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
final Status status = statuses.get(position).getActionableStatus();
setVoteForPoll(status.getId(), status.getPoll().votedCopy(choices));
timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(status.getId(), newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(String statusId, Poll newPoll) {
updateStatus(statusId, s -> s.copyWithPoll(newPoll));
}
private void removeAllByAccountId(String accountId) {
Status status = null;
if (!statuses.isEmpty()) {
status = statuses.get(statusIndex);
}
// using iterator to safely remove items while iterating
Iterator<Status> iterator = statuses.iterator();
while (iterator.hasNext()) {
Status s = iterator.next();
if (s.getAccount().getId().equals(accountId) || s.getActionableStatus().getAccount().getId().equals(accountId)) {
iterator.remove();
}
}
statusIndex = statuses.indexOf(status);
if (statusIndex == -1) {
//the status got removed, close the activity
getActivity().finish();
return;
}
adapter.setDetailedStatusPosition(statusIndex);
adapter.setStatuses(statuses.getPairedCopy());
}
private void sendStatusRequest(final String id) {
mastodonApi.status(id)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
status -> {
int position = setStatus(status);
recyclerView.scrollToPosition(position);
},
throwable -> onThreadRequestFailure(id, throwable)
);
}
private void sendThreadRequest(final String id) {
mastodonApi.statusContext(id)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
context -> {
swipeRefreshLayout.setRefreshing(false);
setContext(context.getAncestors(), context.getDescendants());
},
throwable -> onThreadRequestFailure(id, throwable)
);
}
private void onThreadRequestFailure(final String id, final Throwable throwable) {
View view = getView();
swipeRefreshLayout.setRefreshing(false);
if (view != null) {
Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry, v -> {
sendThreadRequest(id);
sendStatusRequest(id);
})
.show();
} else {
Log.e(TAG, "Network request failed", throwable);
}
}
private int setStatus(Status status) {
if (statuses.size() > 0
&& statusIndex < statuses.size()
&& statuses.get(statusIndex).getId().equals(status.getId())) {
// Do not add this status on refresh, it's already in there.
statuses.set(statusIndex, status);
return statusIndex;
}
int i = statusIndex;
statuses.add(i, status);
adapter.setDetailedStatusPosition(i);
adapter.addItem(i, statuses.getPairedItem(i));
updateRevealIcon();
return i;
}
private void setContext(List<Status> unfilteredAncestors, List<Status> unfilteredDescendants) {
Status mainStatus = null;
// In case of refresh, remove old ancestors and descendants first. We'll remove all blindly,
// as we have no guarantee on their order to be the same as before
int oldSize = statuses.size();
if (oldSize > 1) {
mainStatus = statuses.get(statusIndex);
statuses.clear();
adapter.clearItems();
}
ArrayList<Status> ancestors = new ArrayList<>();
for (Status status : unfilteredAncestors)
if (!filterModel.shouldFilterStatus(status))
ancestors.add(status);
// Insert newly fetched ancestors
statusIndex = ancestors.size();
adapter.setDetailedStatusPosition(statusIndex);
statuses.addAll(0, ancestors);
List<StatusViewData.Concrete> ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex);
if (BuildConfig.DEBUG && ancestors.size() != ancestorsViewDatas.size()) {
String error = String.format(Locale.getDefault(),
"Incorrectly got statusViewData sublist." +
" ancestors.size == %d ancestorsViewDatas.size == %d," +
" statuses.size == %d",
ancestors.size(), ancestorsViewDatas.size(), statuses.size());
throw new AssertionError(error);
}
adapter.addAll(0, ancestorsViewDatas);
if (mainStatus != null) {
// In case we needed to delete everything (which is way easier than deleting
// everything except one), re-insert the remaining status here.
// Not filtering the main status, since the user explicitly chose to be here
statuses.add(statusIndex, mainStatus);
StatusViewData.Concrete viewData = statuses.getPairedItem(statusIndex);
adapter.addItem(statusIndex, viewData);
}
ArrayList<Status> descendants = new ArrayList<>();
for (Status status : unfilteredDescendants)
if (!filterModel.shouldFilterStatus(status))
descendants.add(status);
// Insert newly fetched descendants
statuses.addAll(descendants);
List<StatusViewData.Concrete> descendantsViewData;
descendantsViewData = statuses.getPairedCopy()
.subList(statuses.size() - descendants.size(), statuses.size());
if (BuildConfig.DEBUG && descendants.size() != descendantsViewData.size()) {
String error = String.format(Locale.getDefault(),
"Incorrectly got statusViewData sublist." +
" descendants.size == %d descendantsViewData.size == %d," +
" statuses.size == %d",
descendants.size(), descendantsViewData.size(), statuses.size());
throw new AssertionError(error);
}
adapter.addAll(descendantsViewData);
updateRevealIcon();
}
private void handleFavEvent(FavoriteEvent event) {
updateStatus(event.getStatusId(), (s) -> {
s.setFavourited(event.getFavourite());
return s;
});
}
private void handleReblogEvent(ReblogEvent event) {
updateStatus(event.getStatusId(), (s) -> {
s.setReblogged(event.getReblog());
return s;
});
}
private void handleBookmarkEvent(BookmarkEvent event) {
updateStatus(event.getStatusId(), (s) -> {
s.setBookmarked(event.getBookmark());
return s;
});
}
private void handlePinEvent(PinEvent event) {
updateStatus(event.getStatusId(), (s) -> s.copyWithPinned(event.getPinned()));
}
private void handleStatusComposedEvent(StatusComposedEvent event) {
Status eventStatus = event.getStatus();
if (eventStatus.getInReplyToId() == null) return;
if (eventStatus.getInReplyToId().equals(thisThreadsStatusId)) {
insertStatus(eventStatus, statuses.size());
} else {
// If new status is a reply to some status in the thread, insert new status after it
// We only check statuses below main status, ones on top don't belong to this thread
for (int i = statusIndex; i < statuses.size(); i++) {
Status status = statuses.get(i);
if (eventStatus.getInReplyToId().equals(status.getId())) {
insertStatus(eventStatus, i + 1);
break;
}
}
}
}
private void insertStatus(Status status, int at) {
statuses.add(at, status);
adapter.addItem(at, statuses.getPairedItem(at));
}
private void handleStatusDeletedEvent(StatusDeletedEvent event) {
int index = this.indexOfStatus(event.getStatusId());
if (index != -1) {
statuses.remove(index);
adapter.removeItem(index);
}
}
private int indexOfStatus(String statusId) {
return CollectionsKt.indexOfFirst(this.statuses, (s) -> s.getId().equals(statusId));
}
private void updateRevealIcon() {
ViewThreadActivity activity = ((ViewThreadActivity) getActivity());
if (activity == null) return;
boolean hasAnyWarnings = false;
// Statuses are updated from the main thread so nothing should change while iterating
for (int i = 0; i < statuses.size(); i++) {
if (!TextUtils.isEmpty(statuses.get(i).getSpoilerText())) {
hasAnyWarnings = true;
break;
}
}
if (!hasAnyWarnings) {
activity.setRevealButtonState(ViewThreadActivity.REVEAL_BUTTON_HIDDEN);
return;
}
activity.setRevealButtonState(allExpanded() ? ViewThreadActivity.REVEAL_BUTTON_HIDE :
ViewThreadActivity.REVEAL_BUTTON_REVEAL);
}
private void reloadFilters() {
mastodonApi.getFilters()
.to(autoDisposable(AndroidLifecycleScopeProvider.from(this)))
.subscribe(
(filters) -> {
List<Filter> relevantFilters = CollectionsKt.filter(
filters,
(f) -> f.getContext().contains(Filter.THREAD)
);
filterModel.initWithFilters(relevantFilters);
recyclerView.post(this::applyFilters);
},
(t) -> Log.e(TAG, "Failed to load filters", t)
);
}
private void applyFilters() {
CollectionsKt.removeAll(this.statuses, filterModel::shouldFilterStatus);
adapter.setStatuses(this.statuses.getPairedCopy());
}
}

View file

@ -167,10 +167,15 @@ interface MastodonApi {
@Path("id") statusId: String
): Single<Status>
@GET("api/v1/statuses/{id}/context")
fun statusContext(
@GET("api/v1/statuses/{id}")
suspend fun statusAsync(
@Path("id") statusId: String
): Single<StatusContext>
): NetworkResult<Status>
@GET("api/v1/statuses/{id}/context")
suspend fun statusContext(
@Path("id") statusId: String
): NetworkResult<StatusContext>
@GET("api/v1/statuses/{id}/reblogged_by")
fun statusRebloggedBy(

View file

@ -25,13 +25,15 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
fun Status.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
isCollapsed: Boolean
isCollapsed: Boolean,
isDetailed: Boolean = false
): StatusViewData.Concrete {
return StatusViewData.Concrete(
status = this,
isShowingContent = isShowingContent,
isCollapsed = isCollapsed,
isExpanded = isExpanded,
isDetailed = isDetailed
)
}

View file

@ -42,6 +42,7 @@ sealed class StatusViewData {
*/
/** Whether the status meets the requirement to be collapse */
val isCollapsed: Boolean,
val isDetailed: Boolean = false
) : StatusViewData() {
override val id: String
get() = status.id

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M20,11L7.8,11l5.6,-5.6L12,4l-8,8l8,8l1.4,-1.4L7.8,13L20,13L20,11z"
android:fillColor="@android:color/white"/>
</vector>

View file

@ -1,15 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/windowBackgroundColor"
tools:viewBindingIgnore="true">
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="@dimen/actionbar_elevation" >
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:title="@string/title_view_thread"
app:navigationIcon="@drawable/ic_back"
app:menu="@menu/view_thread_toolbar"/>
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="640dp"
android:layout_height="match_parent"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
android:layout_gravity="center_horizontal">
<androidx.recyclerview.widget.RecyclerView
@ -18,6 +33,20 @@
android:layout_height="match_parent"
android:background="?android:attr/colorBackground"
android:scrollbars="vertical" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/statusView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_gravity="center" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -2,12 +2,9 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_view_thread"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.ViewThreadActivity">
<include layout="@layout/toolbar_basic" />
tools:context="com.keylesspalace.tusky.components.viewthread.ViewThreadActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"

View file

@ -1,17 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swipeRefreshLayout"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="top"
tools:viewBindingIgnore="true">
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="@dimen/actionbar_elevation" >
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:title="@string/title_view_thread"
app:navigationIcon="@drawable/ic_back"
app:menu="@menu/view_thread_toolbar"/>
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground"
android:scrollbars="vertical" />
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
android:layout_gravity="top">
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground"
android:scrollbars="vertical" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/statusView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_gravity="center" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -10,7 +10,15 @@ import java.util.Date
private val fixedDate = Date(1638889052000)
fun mockStatus(id: String = "100") = Status(
fun mockStatus(
id: String = "100",
inReplyToId: String? = null,
inReplyToAccountId: String? = null,
spoilerText: String = "",
reblogged: Boolean = false,
favourited: Boolean = true,
bookmarked: Boolean = true
) = Status(
id = id,
url = "https://mastodon.example/@ConnyDuck/$id",
account = TimelineAccount(
@ -21,8 +29,8 @@ fun mockStatus(id: String = "100") = Status(
url = "https://mastodon.example/@ConnyDuck",
avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg"
),
inReplyToId = null,
inReplyToAccountId = null,
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
reblog = null,
content = "Test",
createdAt = fixedDate,
@ -30,11 +38,11 @@ fun mockStatus(id: String = "100") = Status(
reblogsCount = 1,
favouritesCount = 2,
repliesCount = 3,
reblogged = false,
favourited = true,
bookmarked = true,
reblogged = reblogged,
favourited = favourited,
bookmarked = bookmarked,
sensitive = true,
spoilerText = "",
spoilerText = spoilerText,
visibility = Status.Visibility.PUBLIC,
attachments = ArrayList(),
mentions = emptyList(),
@ -46,11 +54,32 @@ fun mockStatus(id: String = "100") = Status(
card = null
)
fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete(
status = mockStatus(id),
isExpanded = false,
isShowingContent = false,
isCollapsed = true,
fun mockStatusViewData(
id: String = "100",
inReplyToId: String? = null,
inReplyToAccountId: String? = null,
isDetailed: Boolean = false,
spoilerText: String = "",
isExpanded: Boolean = false,
isShowingContent: Boolean = false,
isCollapsed: Boolean = !isDetailed,
reblogged: Boolean = false,
favourited: Boolean = true,
bookmarked: Boolean = true
) = StatusViewData.Concrete(
status = mockStatus(
id = id,
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
spoilerText = spoilerText,
reblogged = reblogged,
favourited = favourited,
bookmarked = bookmarked
),
isExpanded = isExpanded,
isShowingContent = isShowingContent,
isCollapsed = isCollapsed,
isDetailed = isDetailed
)
fun mockStatusEntityWithAccount(

View file

@ -0,0 +1,356 @@
package com.keylesspalace.tusky.components.viewthread
import android.os.Looper.getMainLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.timeline.mockStatus
import com.keylesspalace.tusky.components.timeline.mockStatusViewData
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.stub
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import java.io.IOException
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class ViewThreadViewModelTest {
private lateinit var api: MastodonApi
private lateinit var eventHub: EventHub
private lateinit var viewModel: ViewThreadViewModel
private val threadId = "1234"
@Before
fun setup() {
shadowOf(getMainLooper()).idle()
api = mock()
eventHub = EventHub()
val filterModel = FilterModel()
val timelineCases = TimelineCases(api, eventHub)
val accountManager: AccountManager = mock {
on { activeAccount } doReturn AccountEntity(
id = 1,
domain = "mastodon.test",
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true
)
}
viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager)
}
@Test
fun `should emit status and context when both load`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
}
}
@Test
fun `should emit status even if context fails to load`() {
api.stub {
onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1"))
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException())
}
viewModel.loadThread(threadId)
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true)
),
revealButton = RevealButtonState.NO_BUTTON,
refreshing = false
),
viewModel.uiState.first()
)
}
}
@Test
fun `should emit error when status and context fail to load`() {
api.stub {
onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException())
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException())
}
viewModel.loadThread(threadId)
runBlocking {
assertEquals(
ThreadUiState.Error::class.java,
viewModel.uiState.first().javaClass
)
}
}
@Test
fun `should emit error when status fails to load`() {
api.stub {
onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException())
onBlocking { statusContext(threadId) } doReturn NetworkResult.success(
StatusContext(
ancestors = listOf(mockStatus(id = "1")),
descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1"))
)
)
}
viewModel.loadThread(threadId)
runBlocking {
assertEquals(
ThreadUiState.Error::class.java,
viewModel.uiState.first().javaClass
)
}
}
@Test
fun `should update state when reveal button is toggled`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
viewModel.toggleRevealButton()
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "1", spoilerText = "Test", isExpanded = true),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", isExpanded = true)
),
revealButton = RevealButtonState.HIDE,
refreshing = false
),
viewModel.uiState.first()
)
}
}
@Test
fun `should handle favorite event`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
eventHub.dispatch(FavoriteEvent(statusId = "1", false))
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "1", spoilerText = "Test", favourited = false),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
}
}
@Test
fun `should handle reblog event`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
eventHub.dispatch(ReblogEvent(statusId = "2", true))
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", reblogged = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
}
}
@Test
fun `should handle bookmark event`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
eventHub.dispatch(BookmarkEvent(statusId = "3", false))
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", bookmarked = false)
),
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
}
}
@Test
fun `should remove status`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
viewModel.removeStatus(mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test"))
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
),
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
}
}
@Test
fun `should change status expanded state`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
viewModel.changeExpanded(
true,
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
)
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
}
}
@Test
fun `should change content collapsed state`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
viewModel.changeContentCollapsed(
true,
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
)
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isCollapsed = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
}
}
@Test
fun `should change content showing state`() {
mockSuccessResponses()
viewModel.loadThread(threadId)
viewModel.changeContentShowing(
true,
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
)
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isShowingContent = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
}
}
private fun mockSuccessResponses() {
api.stub {
onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1", spoilerText = "Test"))
onBlocking { statusContext(threadId) } doReturn NetworkResult.success(
StatusContext(
ancestors = listOf(mockStatus(id = "1", spoilerText = "Test")),
descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test"))
)
)
}
}
}